Skip to content

Commit

Permalink
fix: escape values included in dev 404 page (#13039)
Browse files Browse the repository at this point in the history
  • Loading branch information
benmccann authored Nov 25, 2024
1 parent 5f8399d commit d338d46
Show file tree
Hide file tree
Showing 7 changed files with 69 additions and 40 deletions.
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;');
});

0 comments on commit d338d46

Please sign in to comment.