From c5016fc86c4928a26b49dbe144b5569d5d89ac04 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 3 Mar 2026 21:40:37 -0500 Subject: [PATCH] fix: harden attribute escaping by removing URL escape bypass (#15740) --- .changeset/harden-attribute-escaping.md | 5 +++ .../astro/src/runtime/server/render/util.ts | 15 -------- packages/astro/test/astro-attrs.test.js | 4 +-- .../test/units/app/url-attribute-xss.test.js | 36 +++++++++++++++++++ 4 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 .changeset/harden-attribute-escaping.md create mode 100644 packages/astro/test/units/app/url-attribute-xss.test.js diff --git a/.changeset/harden-attribute-escaping.md b/.changeset/harden-attribute-escaping.md new file mode 100644 index 000000000000..f5263b255a96 --- /dev/null +++ b/.changeset/harden-attribute-escaping.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Removes an escape hatch that skipped attribute escaping for URL values containing `&`, ensuring all dynamic attribute values are consistently escaped diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts index d77db44508ab..8982667314d6 100644 --- a/packages/astro/src/runtime/server/render/util.ts +++ b/packages/astro/src/runtime/server/render/util.ts @@ -120,11 +120,6 @@ Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`); } - // Prevents URLs in attributes from being escaped in static builds - if (typeof value === 'string' && value.includes('&') && isHttpUrl(value)) { - return markHTMLString(` ${key}="${toAttributeString(value, false)}"`); - } - // Boolean values only need the key if (htmlBooleanAttributes.test(key)) { return handleBooleanAttribute(key, value, shouldEscape, tagName); @@ -308,13 +303,3 @@ export function promiseWithResolvers(): PromiseWithResolvers { reject, }; } - -const VALID_PROTOCOLS = ['http:', 'https:']; -function isHttpUrl(url: string) { - try { - const parsedUrl = new URL(url); - return VALID_PROTOCOLS.includes(parsedUrl.protocol); - } catch { - return false; - } -} diff --git a/packages/astro/test/astro-attrs.test.js b/packages/astro/test/astro-attrs.test.js index dd5ffdd4d993..23d852d37e33 100644 --- a/packages/astro/test/astro-attrs.test.js +++ b/packages/astro/test/astro-attrs.test.js @@ -70,9 +70,9 @@ describe('Attributes', async () => { "normal attributes should not have values if it's an empty string", ); - // cheerio will unescape the values, so checking that the url rendered unescaped to begin with has to be done manually + // cheerio will unescape the values, so checking that the url rendered escaped has to be done manually assert.equal( - html.includes('https://example.com/api/og?title=hello&description=somedescription'), + html.includes('https://example.com/api/og?title=hello&description=somedescription'), true, ); diff --git a/packages/astro/test/units/app/url-attribute-xss.test.js b/packages/astro/test/units/app/url-attribute-xss.test.js new file mode 100644 index 000000000000..56aa4d401c90 --- /dev/null +++ b/packages/astro/test/units/app/url-attribute-xss.test.js @@ -0,0 +1,36 @@ +// @ts-check +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { addAttribute } from '../../../dist/runtime/server/render/util.js'; + +describe('addAttribute URL XSS', () => { + it('escapes double quotes in HTTP URLs containing &', () => { + const maliciousUrl = 'https://evil.com/?a=1&b=2"+onmouseover="alert(1)'; + const result = String(addAttribute(maliciousUrl, 'href')); + + // The " must be escaped so the value stays inside a single attribute + assert.ok(result.includes('"'), `double quotes should be escaped, got: ${result}`); + assert.match( + result, + /^\s+href="[^"]*"$/, + `should be a single well-formed attribute, got: ${result}`, + ); + }); + + it('escapes & in HTTP URLs', () => { + const url = 'https://example.com/?a=1&b=2&c=3'; + const result = String(addAttribute(url, 'href')); + + assert.ok(result.includes('&'), `ampersands should be escaped, got: ${result}`); + assert.match( + result, + /^\s+href="[^"]*"$/, + `should be a single well-formed attribute, got: ${result}`, + ); + }); + + it('handles null and undefined values', () => { + assert.equal(addAttribute(null, 'href'), ''); + assert.equal(addAttribute(undefined, 'href'), ''); + }); +});