GHSA-J687-52P2-XCFF
Vulnerability from github – Published: 2026-04-21 20:39 – Updated: 2026-04-21 20:39Summary
The defineScriptVars function in Astro's server-side rendering pipeline uses a case-sensitive regex /<\/script>/g to sanitize values injected into inline <script> tags via the define:vars directive. HTML parsers close <script> elements case-insensitively and also accept whitespace or / before the closing >, allowing an attacker to bypass the sanitization with payloads like </Script>, </script >, or </script/> 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<any, any>) {
let output = '';
for (const [key, value] of Object.entries(vars)) {
output += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(
/<\/script>/g, // ← Case-sensitive, exact match only
'\\x3C/script>',
)};\n`;
}
return markHTMLString(output);
}
This function is called from renderElement at util.ts:172-174 when a <script> element has define:vars:
if (name === 'script') {
delete props.hoist;
children = defineScriptVars(defineVars) + '\n' + children;
}
The regex /<\/script>/g fails to match three classes of closing script tags that HTML parsers accept per the HTML specification §13.2.6.4:
- Case variations:
</Script>,</SCRIPT>,</sCrIpT>— HTML tag names are case-insensitive but the regex has noiflag. - Whitespace before
>:</script >,</script\t>,</script\n>— after the tag name, the HTML tokenizer enters the "before attribute name" state on ASCII whitespace. - Self-closing slash:
</script/>— the tokenizer enters "self-closing start tag" state on/.
JSON.stringify() does not escape <, >, 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 <script> tag → renderElement → defineScriptVars → incomplete sanitization → injected into <script> 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('name') || 'World';
---
<html>
<body>
<h1>Hello</h1>
<script define:vars={{ name }}>
console.log(name);
</script>
</body>
</html>
Step 2: Ensure SSR is enabled in astro.config.mjs:
export default defineConfig({
output: 'server'
});
Step 3: Start the dev server and visit:
http://localhost:4321/?name=</Script><img/src=x%20onerror=alert(document.cookie)>
Step 4: View the HTML source. The output contains:
<script>const name = "</Script><img/src=x onerror=alert(document.cookie)>";
console.log(name);
</script>
The browser's HTML parser matches </Script> case-insensitively, closing the script block. The <img onerror=alert(document.cookie)> is then parsed as HTML and the JavaScript in onerror executes.
Alternative bypass payloads:
/?name=</script ><img/src=x onerror=alert(1)>
/?name=</script/><img/src=x onerror=alert(1)>
/?name=</SCRIPT><img/src=x onerror=alert(1)>
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 <script> 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 < characters in the JSON output:
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);
}
This is the standard approach used by frameworks like Next.js and Rails. Replacing every < with \u003c is safe inside JSON string contexts (JavaScript treats \u003c as < at runtime) and eliminates all possible </script> variants including case variations, whitespace, and self-closing forms.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "astro"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.1.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-41067"
],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-21T20:39:49Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `defineScriptVars` function in Astro\u0027s server-side rendering pipeline uses a case-sensitive regex `/\u003c\\/script\u003e/g` to sanitize values injected into inline `\u003cscript\u003e` tags via the `define:vars` directive. HTML parsers close `\u003cscript\u003e` elements case-insensitively and also accept whitespace or `/` before the closing `\u003e`, allowing an attacker to bypass the sanitization with payloads like `\u003c/Script\u003e`, `\u003c/script \u003e`, or `\u003c/script/\u003e` and inject arbitrary HTML/JavaScript.\n\n## Details\n\nThe vulnerable function is `defineScriptVars` at `packages/astro/src/runtime/server/render/util.ts:42-53`:\n\n```typescript\nexport function defineScriptVars(vars: Record\u003cany, any\u003e) {\n\tlet output = \u0027\u0027;\n\tfor (const [key, value] of Object.entries(vars)) {\n\t\toutput += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(\n\t\t\t/\u003c\\/script\u003e/g, // \u2190 Case-sensitive, exact match only\n\t\t\t\u0027\\\\x3C/script\u003e\u0027,\n\t\t)};\\n`;\n\t}\n\treturn markHTMLString(output);\n}\n```\n\nThis function is called from `renderElement` at `util.ts:172-174` when a `\u003cscript\u003e` element has `define:vars`:\n\n```typescript\nif (name === \u0027script\u0027) {\n\tdelete props.hoist;\n\tchildren = defineScriptVars(defineVars) + \u0027\\n\u0027 + children;\n}\n```\n\nThe regex `/\u003c\\/script\u003e/g` fails to match three classes of closing script tags that HTML parsers accept per the [HTML specification \u00a713.2.6.4](https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody):\n\n1. **Case variations**: `\u003c/Script\u003e`, `\u003c/SCRIPT\u003e`, `\u003c/sCrIpT\u003e` \u2014 HTML tag names are case-insensitive but the regex has no `i` flag.\n2. **Whitespace before `\u003e`**: `\u003c/script \u003e`, `\u003c/script\\t\u003e`, `\u003c/script\\n\u003e` \u2014 after the tag name, the HTML tokenizer enters the \"before attribute name\" state on ASCII whitespace.\n3. **Self-closing slash**: `\u003c/script/\u003e` \u2014 the tokenizer enters \"self-closing start tag\" state on `/`.\n\n`JSON.stringify()` does not escape `\u003c`, `\u003e`, or `/` characters, so all these payloads pass through serialization unchanged.\n\n**Execution flow:** User-controlled input (e.g., `Astro.url.searchParams`) \u2192 assigned to a variable \u2192 passed via `define:vars` on a `\u003cscript\u003e` tag \u2192 `renderElement` \u2192 `defineScriptVars` \u2192 incomplete sanitization \u2192 injected into `\u003cscript\u003e` block in HTML response \u2192 browser closes the script element early \u2192 attacker-controlled HTML parsed and executed.\n\n## PoC\n\n**Step 1:** Create an SSR Astro page (`src/pages/index.astro`):\n\n```astro\n---\nconst name = Astro.url.searchParams.get(\u0027name\u0027) || \u0027World\u0027;\n---\n\u003chtml\u003e\n\u003cbody\u003e\n \u003ch1\u003eHello\u003c/h1\u003e\n \u003cscript define:vars={{ name }}\u003e\n console.log(name);\n \u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n**Step 2:** Ensure SSR is enabled in `astro.config.mjs`:\n\n```js\nexport default defineConfig({\n output: \u0027server\u0027\n});\n```\n\n**Step 3:** Start the dev server and visit:\n\n```\nhttp://localhost:4321/?name=\u003c/Script\u003e\u003cimg/src=x%20onerror=alert(document.cookie)\u003e\n```\n\n**Step 4:** View the HTML source. The output contains:\n\n```html\n\u003cscript\u003econst name = \"\u003c/Script\u003e\u003cimg/src=x onerror=alert(document.cookie)\u003e\";\n console.log(name);\n\u003c/script\u003e\n```\n\nThe browser\u0027s HTML parser matches `\u003c/Script\u003e` case-insensitively, closing the script block. The `\u003cimg onerror=alert(document.cookie)\u003e` is then parsed as HTML and the JavaScript in `onerror` executes.\n\n**Alternative bypass payloads:**\n\n```\n/?name=\u003c/script \u003e\u003cimg/src=x onerror=alert(1)\u003e\n/?name=\u003c/script/\u003e\u003cimg/src=x onerror=alert(1)\u003e\n/?name=\u003c/SCRIPT\u003e\u003cimg/src=x onerror=alert(1)\u003e\n```\n\n## Impact\n\nAn attacker can execute arbitrary JavaScript in the context of a victim\u0027s browser session on any SSR Astro application that passes request-derived data to `define:vars` on a `\u003cscript\u003e` tag. This is a documented and expected usage pattern in Astro.\n\nExploitation enables:\n- **Session hijacking** via cookie theft (`document.cookie`)\n- **Credential theft** by injecting fake login forms or keyloggers\n- **Defacement** of the rendered page\n- **Redirection** to attacker-controlled domains\n\nThe 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.\n\n## Recommended Fix\n\nReplace 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 `\u003c` characters in the JSON output:\n\n```typescript\nexport function defineScriptVars(vars: Record\u003cany, any\u003e) {\n\tlet output = \u0027\u0027;\n\tfor (const [key, value] of Object.entries(vars)) {\n\t\toutput += `const ${toIdent(key)} = ${JSON.stringify(value)?.replace(\n\t\t\t/\u003c/g,\n\t\t\t\u0027\\\\u003c\u0027,\n\t\t)};\\n`;\n\t}\n\treturn markHTMLString(output);\n}\n```\n\nThis is the standard approach used by frameworks like Next.js and Rails. Replacing every `\u003c` with `\\u003c` is safe inside JSON string contexts (JavaScript treats `\\u003c` as `\u003c` at runtime) and eliminates all possible `\u003c/script\u003e` variants including case variations, whitespace, and self-closing forms.",
"id": "GHSA-j687-52p2-xcff",
"modified": "2026-04-21T20:39:49Z",
"published": "2026-04-21T20:39:49Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/withastro/astro/security/advisories/GHSA-j687-52p2-xcff"
},
{
"type": "PACKAGE",
"url": "https://github.com/withastro/astro"
},
{
"type": "WEB",
"url": "https://github.com/withastro/astro/releases/tag/astro@6.1.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Astro: XSS in define:vars via incomplete \u003c/script\u003e tag sanitization"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.