Cross-Site Scripting attack (XSS) on dev mode 404 page in SvelteKit
Description
SvelteKit is a framework for rapidly developing robust, performant web applications using Svelte. "Unsanitized input from *the request URL* flows into end, where it is used to render an HTML page returned to the user. This may result in a Cross-Site Scripting attack (XSS)." The files packages/kit/src/exports/vite/dev/index.js and packages/kit/src/exports/vite/utils.js both contain user controllable data which under specific conditions may flow to dev mode pages. There is little to no expected impact. The Vite development is not exposed to the network by default and even if someone were able to trick a developer into executing an XSS against themselves, a development database should not have any sensitive data. None the less this issue has been addressed in version 2.8.3 and all users are advised to upgrade.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@sveltejs/kitnpm | < 2.8.3 | 2.8.3 |
Affected products
1Patches
1d338d4635a7ffix: escape values included in dev 404 page (#13039)
7 files changed · +69 −40
.changeset/five-maps-yawn.md+5 −0 added@@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: escape values included in dev 404 page
packages/kit/src/core/postbuild/prerender.js+5 −4 modified@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'; import { installPolyfills } from '../../exports/node/polyfills.js'; import { mkdirp, posixify, walk } from '../../utils/filesystem.js'; import { decode_uri, is_root_relative, resolve } from '../../utils/url.js'; -import { escape_html_attr } from '../../utils/escape.js'; +import { escape_html } from '../../utils/escape.js'; import { logger } from '../utils.js'; import { load_config } from '../config/index.js'; import { get_route_segments } from '../../utils/routing.js'; @@ -359,9 +359,10 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { dest, `<script>location.href=${devalue.uneval( location - )};</script><meta http-equiv="refresh" content=${escape_html_attr( - `0;url=${location}` - )}>` + )};</script><meta http-equiv="refresh" content="${escape_html( + `0;url=${location}`, + true + )}">` ); written.add(file);
packages/kit/src/exports/vite/utils.js+9 −2 modified@@ -3,6 +3,7 @@ import { loadEnv } from 'vite'; import { posixify } from '../../utils/filesystem.js'; import { negotiate } from '../../utils/http.js'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; +import { escape_html } from '../../utils/escape.js'; /** * Transforms kit.alias to a valid vite.resolve.alias array. @@ -89,11 +90,17 @@ export function not_found(req, res, base) { if (type === 'text/html') { res.setHeader('Content-Type', 'text/html'); res.end( - `The server is configured with a public base URL of ${base} - did you mean to visit <a href="${prefixed}">${prefixed}</a> instead?` + `The server is configured with a public base URL of ${escape_html( + base + )} - did you mean to visit <a href="${escape_html(prefixed, true)}">${escape_html( + prefixed + )}</a> instead?` ); } else { res.end( - `The server is configured with a public base URL of ${base} - did you mean to visit ${prefixed} instead?` + `The server is configured with a public base URL of ${escape_html( + base + )} - did you mean to visit ${escape_html(prefixed)} instead?` ); } }
packages/kit/src/runtime/server/page/csp.js+2 −2 modified@@ -1,4 +1,4 @@ -import { escape_html_attr } from '../../../utils/escape.js'; +import { escape_html } from '../../../utils/escape.js'; import { base64, sha256 } from './crypto.js'; const array = new Uint8Array(16); @@ -300,7 +300,7 @@ class CspProvider extends BaseProvider { return; } - return `<meta http-equiv="content-security-policy" content=${escape_html_attr(content)}>`; + return `<meta http-equiv="content-security-policy" content="${escape_html(content, true)}">`; } }
packages/kit/src/runtime/server/page/serialize_data.js+2 −2 modified@@ -1,4 +1,4 @@ -import { escape_html_attr } from '../../../utils/escape.js'; +import { escape_html } from '../../../utils/escape.js'; import { hash } from '../../hash.js'; /** @@ -70,7 +70,7 @@ export function serialize_data(fetched, filter, prerendering = false) { const attrs = [ 'type="application/json"', 'data-sveltekit-fetched', - `data-url=${escape_html_attr(fetched.url)}` + `data-url="${escape_html(fetched.url, true)}"` ]; if (fetched.is_b64) {
packages/kit/src/utils/escape.js+36 −20 modified@@ -6,41 +6,57 @@ const escape_html_attr_dict = { '&': '&', '"': '"' + // Svelte also escapes < because the escape function could be called inside a `noscript` there + // https://github.com/sveltejs/svelte/security/advisories/GHSA-8266-84wp-wv5c + // However, that doesn't apply in SvelteKit }; +/** + * @type {Record<string, string>} + */ +const escape_html_dict = { + '&': '&', + '<': '<' +}; + +const surrogates = // high surrogate without paired low surrogate + '[\\ud800-\\udbff](?![\\udc00-\\udfff])|' + + // a valid surrogate pair, the only match with 2 code units + // we match it so that we can match unpaired low surrogates in the same pass + // TODO: use lookbehind assertions once they are widely supported: (?<![\ud800-udbff])[\udc00-\udfff] + '[\\ud800-\\udbff][\\udc00-\\udfff]|' + + // unpaired low surrogate (see previous match) + '[\\udc00-\\udfff]'; + const escape_html_attr_regex = new RegExp( - // special characters - `[${Object.keys(escape_html_attr_dict).join('')}]|` + - // high surrogate without paired low surrogate - '[\\ud800-\\udbff](?![\\udc00-\\udfff])|' + - // a valid surrogate pair, the only match with 2 code units - // we match it so that we can match unpaired low surrogates in the same pass - // TODO: use lookbehind assertions once they are widely supported: (?<![\ud800-udbff])[\udc00-\udfff] - '[\\ud800-\\udbff][\\udc00-\\udfff]|' + - // unpaired low surrogate (see previous match) - '[\\udc00-\\udfff]', + `[${Object.keys(escape_html_attr_dict).join('')}]|` + surrogates, + 'g' +); + +const escape_html_regex = new RegExp( + `[${Object.keys(escape_html_dict).join('')}]|` + surrogates, 'g' ); /** - * Formats a string to be used as an attribute's value in raw HTML. - * - * It escapes unpaired surrogates (which are allowed in js strings but invalid in HTML), escapes - * characters that are special in attributes, and surrounds the whole string in double-quotes. + * Escapes unpaired surrogates (which are allowed in js strings but invalid in HTML) and + * escapes characters that are special. * * @param {string} str - * @returns {string} Escaped string surrounded by double-quotes. - * @example const html = `<tag data-value=${escape_html_attr('value')}>...</tag>`; + * @param {boolean} [is_attr] + * @returns {string} escaped string + * @example const html = `<tag data-value="${escape_html('value', true)}">...</tag>`; */ -export function escape_html_attr(str) { - const escaped_str = str.replace(escape_html_attr_regex, (match) => { +export function escape_html(str, is_attr) { + const dict = is_attr ? escape_html_attr_dict : escape_html_dict; + const escaped_str = str.replace(is_attr ? escape_html_attr_regex : escape_html_regex, (match) => { if (match.length === 2) { // valid surrogate pair return match; } - return escape_html_attr_dict[match] ?? `&#${match.charCodeAt(0)};`; + return dict[match] ?? `&#${match.charCodeAt(0)};`; }); - return `"${escaped_str}"`; + return escaped_str; }
packages/kit/src/utils/escape.spec.js+10 −10 modified@@ -1,19 +1,19 @@ import { assert, test } from 'vitest'; -import { escape_html_attr } from './escape.js'; +import { escape_html } from './escape.js'; test('escape_html_attr escapes special attribute characters', () => { assert.equal( - escape_html_attr('some "values" are &special here, <others> aren\'t.'), - '"some "values" are &special here, <others> aren\'t."' + escape_html('some "values" are &special here, <others> aren\'t.', true), + "some "values" are &special here, <others> aren't." ); }); test('escape_html_attr escapes invalid surrogates', () => { - assert.equal(escape_html_attr('\ud800\udc00'), '"\ud800\udc00"'); - assert.equal(escape_html_attr('\ud800'), '"�"'); - assert.equal(escape_html_attr('\udc00'), '"�"'); - assert.equal(escape_html_attr('\udc00\ud800'), '"��"'); - assert.equal(escape_html_attr('\ud800\ud800\udc00'), '"�\ud800\udc00"'); - assert.equal(escape_html_attr('\ud800\udc00\udc00'), '"\ud800\udc00�"'); - assert.equal(escape_html_attr('\ud800\ud800\udc00\udc00'), '"�\ud800\udc00�"'); + assert.equal(escape_html('\ud800\udc00', true), '\ud800\udc00'); + assert.equal(escape_html('\ud800', true), '�'); + assert.equal(escape_html('\udc00', true), '�'); + assert.equal(escape_html('\udc00\ud800', true), '��'); + assert.equal(escape_html('\ud800\ud800\udc00', true), '�\ud800\udc00'); + assert.equal(escape_html('\ud800\udc00\udc00', true), '\ud800\udc00�'); + assert.equal(escape_html('\ud800\ud800\udc00\udc00', true), '�\ud800\udc00�'); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-rjjv-87mx-6x3hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-53261ghsaADVISORY
- github.com/sveltejs/kit/commit/d338d4635a7fd947ba5112df6ee632c4a0979438ghsax_refsource_MISCWEB
- github.com/sveltejs/kit/pull/13039ghsaWEB
- github.com/sveltejs/kit/releases/tag/%40sveltejs%2Fkit%402.8.3ghsaWEB
- github.com/sveltejs/kit/security/advisories/GHSA-rjjv-87mx-6x3hghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.