Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: escape values included in dev 404 page #13039

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-maps-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: escape values included in dev 404 page
9 changes: 5 additions & 4 deletions packages/kit/src/core/postbuild/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions packages/kit/src/exports/vite/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?`
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/server/page/csp.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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)}">`;
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/server/page/serialize_data.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { escape_html_attr } from '../../../utils/escape.js';
import { escape_html } from '../../../utils/escape.js';
import { hash } from '../../hash.js';

/**
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 36 additions & 20 deletions packages/kit/src/utils/escape.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,57 @@
const escape_html_attr_dict = {
'&': '&amp;',
'"': '&quot;'
// 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 = {
'&': '&amp;',
'<': '&lt;'
};

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;
}
20 changes: 10 additions & 10 deletions packages/kit/src/utils/escape.spec.js
Original file line number Diff line number Diff line change
@@ -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 &quot;values&quot; are &amp;special here, <others> aren\'t."'
escape_html('some "values" are &special here, <others> aren\'t.', true),
"some &quot;values&quot; are &amp;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'), '"&#55296;"');
assert.equal(escape_html_attr('\udc00'), '"&#56320;"');
assert.equal(escape_html_attr('\udc00\ud800'), '"&#56320;&#55296;"');
assert.equal(escape_html_attr('\ud800\ud800\udc00'), '"&#55296;\ud800\udc00"');
assert.equal(escape_html_attr('\ud800\udc00\udc00'), '"\ud800\udc00&#56320;"');
assert.equal(escape_html_attr('\ud800\ud800\udc00\udc00'), '"&#55296;\ud800\udc00&#56320;"');
assert.equal(escape_html('\ud800\udc00', true), '\ud800\udc00');
assert.equal(escape_html('\ud800', true), '&#55296;');
assert.equal(escape_html('\udc00', true), '&#56320;');
assert.equal(escape_html('\udc00\ud800', true), '&#56320;&#55296;');
assert.equal(escape_html('\ud800\ud800\udc00', true), '&#55296;\ud800\udc00');
assert.equal(escape_html('\ud800\udc00\udc00', true), '\ud800\udc00&#56320;');
assert.equal(escape_html('\ud800\ud800\udc00\udc00', true), '&#55296;\ud800\udc00&#56320;');
});
Loading