diff --git a/.changeset/silent-cycles-reply.md b/.changeset/silent-cycles-reply.md new file mode 100644 index 000000000000..66f12e14d322 --- /dev/null +++ b/.changeset/silent-cycles-reply.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[breaking] exclude headers from serialized responses by default, add `filterSerializedResponseHeaders` `resolve` option diff --git a/.gitignore b/.gitignore index d005b0ecb55a..77c2b88100f7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist test-results/ package-lock.json yarn.lock +vite.config.js.timestamp-* /packages/create-svelte/template/CHANGELOG.md /packages/package/test/**/package /documentation/types.js @@ -16,3 +17,4 @@ yarn.lock .turbo .vercel .test-tmp + diff --git a/documentation/docs/05-load.md b/documentation/docs/05-load.md index 0aaf5389dffc..6da83a4c25f2 100644 --- a/documentation/docs/05-load.md +++ b/documentation/docs/05-load.md @@ -143,7 +143,7 @@ export async function load({ depends }) { - it can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request - it can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context) - internal requests (e.g. for `+server.js` routes) go direct to the handler function when running on the server, without the overhead of an HTTP call -- during server-side rendering, the response will be captured and inlined into the rendered HTML +- during server-side rendering, the response will be captured and inlined into the rendered HTML. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](/docs/hooks#handle) - during hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request > Cookies will only be passed through if the target host is the same as the SvelteKit application or a more specific subdomain of it. diff --git a/documentation/docs/06-hooks.md b/documentation/docs/06-hooks.md index 18f191961aa1..8992b92ae266 100644 --- a/documentation/docs/06-hooks.md +++ b/documentation/docs/06-hooks.md @@ -64,13 +64,15 @@ You can add call multiple `handle` functions with [the `sequence` helper functio `resolve` also supports a second, optional parameter that gives you more control over how the response will be rendered. That parameter is an object that can have the following fields: - `transformPageChunk(opts: { html: string, done: boolean }): MaybePromise` — applies custom transforms to HTML. If `done` is true, it's the final chunk. Chunks are not guaranteed to be well-formed HTML (they could include an element's opening tag but not its closing tag, for example) but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. +- `filterSerializedResponseHeaders(name: string, value: string): boolean` — determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. By default, none will be included. ```js /// file: src/hooks.js /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { const response = await resolve(event, { - transformPageChunk: ({ html }) => html.replace('old', 'new') + transformPageChunk: ({ html }) => html.replace('old', 'new'), + filterSerializedResponseHeaders: (name) => name.startsWith('x-') }); return response; diff --git a/packages/kit/src/core/prerender/prerender.js b/packages/kit/src/core/prerender/prerender.js index 52bd4d7bdc32..f5e4d981c8fc 100644 --- a/packages/kit/src/core/prerender/prerender.js +++ b/packages/kit/src/core/prerender/prerender.js @@ -237,10 +237,11 @@ export async function prerender() { const encoded_dependency_path = new URL(dependency_path, 'http://localhost').pathname; const decoded_dependency_path = decodeURI(encoded_dependency_path); - const prerender = result.response.headers.get('x-sveltekit-prerender'); + const headers = Object.fromEntries(result.response.headers); + const prerender = headers['x-sveltekit-prerender']; if (prerender) { - const route_id = /** @type {string} */ (result.response.headers.get('x-sveltekit-routeid')); + const route_id = headers['x-sveltekit-routeid']; const existing_value = prerender_map.get(route_id); if (existing_value !== 'auto') { prerender_map.set(route_id, prerender === 'true' ? true : 'auto'); @@ -259,7 +260,10 @@ export async function prerender() { ); } - if (config.prerender.crawl && response.headers.get('content-type') === 'text/html') { + // avoid triggering `filterSerializeResponseHeaders` guard + const headers = Object.fromEntries(response.headers); + + if (config.prerender.crawl && headers['content-type'] === 'text/html') { for (const href of crawl(body.toString())) { if (href.startsWith('data:') || href.startsWith('#')) continue; @@ -288,7 +292,9 @@ export async function prerender() { */ function save(category, response, body, decoded, encoded, referrer, referenceType) { const response_type = Math.floor(response.status / 100); - const type = /** @type {string} */ (response.headers.get('content-type')); + const headers = Object.fromEntries(response.headers); + + const type = headers['content-type']; const is_html = response_type === REDIRECT || type === 'text/html'; const file = output_filename(decoded, is_html); @@ -297,7 +303,7 @@ export async function prerender() { if (written.has(file)) return; if (response_type === REDIRECT) { - const location = response.headers.get('location'); + const location = headers['location']; if (location) { const resolved = resolve(encoded, location); @@ -305,7 +311,7 @@ export async function prerender() { enqueue(decoded, decodeURI(resolved), resolved); } - if (!response.headers.get('x-sveltekit-normalize')) { + if (!headers['x-sveltekit-normalize']) { mkdirp(dirname(dest)); log.warn(`${response.status} ${decoded} -> ${location}`); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index ec142c6ece25..45acb2e474ca 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -14,6 +14,8 @@ import { DATA_SUFFIX } from '../../constants.js'; /** @param {{ html: string }} opts */ const default_transform = ({ html }) => html; +const default_filter = () => false; + /** @type {import('types').Respond} */ export async function respond(request, options, state) { let url = new URL(request.url); @@ -201,7 +203,8 @@ export async function respond(request, options, state) { /** @type {import('types').RequiredResolveOptions} */ let resolve_opts = { - transformPageChunk: default_transform + transformPageChunk: default_transform, + filterSerializedResponseHeaders: default_filter }; /** @@ -226,7 +229,8 @@ export async function respond(request, options, state) { } resolve_opts = { - transformPageChunk: opts.transformPageChunk || default_transform + transformPageChunk: opts.transformPageChunk || default_transform, + filterSerializedResponseHeaders: opts.filterSerializedResponseHeaders || default_filter }; } diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js index 8ffe846d9a0e..525d6f755e2e 100644 --- a/packages/kit/src/runtime/server/page/fetch.js +++ b/packages/kit/src/runtime/server/page/fetch.js @@ -10,9 +10,10 @@ import { domain_matches, path_matches } from './cookie.js'; * state: import('types').SSRState; * route: import('types').SSRRoute | import('types').SSRErrorPage; * prerender_default?: import('types').PrerenderOption; + * resolve_opts: import('types').RequiredResolveOptions; * }} opts */ -export function create_fetch({ event, options, state, route, prerender_default }) { +export function create_fetch({ event, options, state, route, prerender_default, resolve_opts }) { /** @type {import('./types').Fetched[]} */ const fetched = []; @@ -189,16 +190,6 @@ export function create_fetch({ event, options, state, route, prerender_default } async function text() { const body = await response.text(); - // TODO just pass `response.headers`, for processing inside `serialize_data` - /** @type {import('types').ResponseHeaders} */ - const headers = {}; - for (const [key, value] of response.headers) { - // TODO skip others besides set-cookie and etag? - if (key !== 'set-cookie' && key !== 'etag') { - headers[key] = value; - } - } - if (!body || typeof body === 'string') { const status_number = Number(response.status); if (isNaN(status_number)) { @@ -214,14 +205,27 @@ export function create_fetch({ event, options, state, route, prerender_default } ? request.url.slice(event.url.origin.length) : request.url, method: request.method, - body: /** @type {string | undefined} */ (request_body), - response: { - status: status_number, - statusText: response.statusText, - headers, - body - } + request_body: /** @type {string | undefined} */ (request_body), + response_body: body, + response: response }); + + // ensure that excluded headers can't be read + const get = response.headers.get; + response.headers.get = (key) => { + const lower = key.toLowerCase(); + const value = get.call(response.headers, lower); + if (value && !lower.startsWith('x-sveltekit-')) { + const included = resolve_opts.filterSerializedResponseHeaders(lower, value); + if (!included) { + throw new Error( + `Failed to get response header "${lower}" — it must be included by the \`filterSerializedResponseHeaders\` option: https://kit.svelte.dev/docs/hooks#handle` + ); + } + } + + return value; + }; } if (dependency) { diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index c704cdc83d15..d2b9c5b5858e 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -131,7 +131,8 @@ export async function render_page(event, route, page, options, state, resolve_op options, state, route, - prerender_default: should_prerender + prerender_default: should_prerender, + resolve_opts }); if (get_option(nodes, 'ssr') === false) { diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index ec03e07122dd..01beef8914a5 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -284,7 +284,11 @@ export async function render_response({ } if (page_config.ssr && page_config.csr) { - body += `\n\t${fetched.map((item) => serialize_data(item, !!state.prerendering)).join('\n\t')}`; + body += `\n\t${fetched + .map((item) => + serialize_data(item, resolve_opts.filterSerializedResponseHeaders, !!state.prerendering) + ) + .join('\n\t')}`; } if (options.service_worker) { @@ -321,6 +325,7 @@ export async function render_response({ })) || ''; const headers = new Headers({ + 'x-sveltekit-page': 'true', 'content-type': 'text/html', etag: `"${hash(html)}"` }); diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 739bc06b3674..b7a3964ad79d 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -25,7 +25,8 @@ export async function respond_with_error({ event, options, state, status, error, event, options, state, - route: GENERIC_ERROR + route: GENERIC_ERROR, + resolve_opts }); try { diff --git a/packages/kit/src/runtime/server/page/serialize_data.js b/packages/kit/src/runtime/server/page/serialize_data.js index 24a86a260b53..3b278657beaf 100644 --- a/packages/kit/src/runtime/server/page/serialize_data.js +++ b/packages/kit/src/runtime/server/page/serialize_data.js @@ -35,15 +35,35 @@ const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g'); * and that the resulting string isn't further modified. * * @param {import('./types.js').Fetched} fetched + * @param {(name: string, value: string) => boolean} filter * @param {boolean} [prerendering] * @returns {string} The raw HTML of a script element carrying the JSON payload. * @example const html = serialize_data('/data.json', null, { foo: 'bar' }); */ -export function serialize_data(fetched, prerendering = false) { - const safe_payload = JSON.stringify(fetched.response).replace( - pattern, - (match) => replacements[match] - ); +export function serialize_data(fetched, filter, prerendering = false) { + /** @type {Record} */ + const headers = {}; + + let cache_control = null; + let age = null; + + for (const [key, value] of fetched.response.headers) { + if (filter(key, value)) { + headers[key] = value; + } + + if (key === 'cache-control') cache_control = value; + if (key === 'age') age = value; + } + + const payload = { + status: fetched.response.status, + statusText: fetched.response.statusText, + headers, + body: fetched.response_body + }; + + const safe_payload = JSON.stringify(payload).replace(pattern, (match) => replacements[match]); const attrs = [ 'type="application/json"', @@ -51,20 +71,15 @@ export function serialize_data(fetched, prerendering = false) { `data-url=${escape_html_attr(fetched.url)}` ]; - if (fetched.body) { - attrs.push(`data-hash=${escape_html_attr(hash(fetched.body))}`); + if (fetched.request_body) { + attrs.push(`data-hash=${escape_html_attr(hash(fetched.request_body))}`); } - if (!prerendering && fetched.method === 'GET') { - const cache_control = /** @type {string} */ (fetched.response.headers['cache-control']); - if (cache_control) { - const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control); - if (match) { - const age = /** @type {string} */ (fetched.response.headers['age']) ?? '0'; - - const ttl = +match[1] - +age; - attrs.push(`data-ttl="${ttl}"`); - } + if (!prerendering && fetched.method === 'GET' && cache_control) { + const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control); + if (match) { + const ttl = +match[1] - +(age ?? '0'); + attrs.push(`data-ttl="${ttl}"`); } } diff --git a/packages/kit/src/runtime/server/page/serialize_data.spec.js b/packages/kit/src/runtime/server/page/serialize_data.spec.js index 3383c7f98bb2..57ef9ee14d32 100644 --- a/packages/kit/src/runtime/server/page/serialize_data.spec.js +++ b/packages/kit/src/runtime/server/page/serialize_data.spec.js @@ -3,39 +3,41 @@ import * as assert from 'uvu/assert'; import { serialize_data } from './serialize_data.js'; test('escapes slashes', () => { + const response_body = '' ); }); test('escapes exclamation marks', () => { + const response_body = 'alert("xss")'; + assert.equal( - serialize_data({ - url: 'foo', - method: 'GET', - body: null, - response: { - status: 200, - statusText: 'OK', - headers: {}, - body: 'alert("xss")' - } - }), + serialize_data( + { + url: 'foo', + method: 'GET', + request_body: null, + response_body, + response: new Response(response_body) + }, + () => false + ), '' ); }); @@ -43,19 +45,19 @@ test('escapes exclamation marks', () => { test('escapes the attribute values', () => { const raw = 'an "attr" & a \ud800'; const escaped = 'an "attr" & a �'; + const response_body = ''; assert.equal( - serialize_data({ - url: raw, - method: 'GET', - body: null, - response: { - status: 200, - statusText: 'OK', - headers: {}, - body: '' - } - }), - `` + serialize_data( + { + url: raw, + method: 'GET', + request_body: null, + response_body, + response: new Response(response_body) + }, + () => false + ), + `` ); }); diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 4da2b3d18171..648baaf7bf05 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -1,16 +1,12 @@ -import { ResponseHeaders, SSRNode, CspDirectives } from 'types'; +import { SSRNode, CspDirectives } from 'types'; import { HttpError } from '../../control.js'; export interface Fetched { url: string; method: string; - body?: string | null; - response: { - status: number; - statusText: string; - headers: ResponseHeaders; - body: string; - }; + request_body?: string | null; + response_body: string; + response: Response; } export interface FetchState { diff --git a/packages/kit/test/apps/basics/src/routes/load/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/+page.svelte index 1685c738ef3e..9c46790b79c9 100644 --- a/packages/kit/test/apps/basics/src/routes/load/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/load/+page.svelte @@ -8,7 +8,7 @@ fetch request fetch relative fetch credentialed -fetch headers +fetch headers large response raw body server fetch request diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-headers.json/+server.js b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers.json/+server.js similarity index 80% rename from packages/kit/test/apps/basics/src/routes/load/fetch-headers.json/+server.js rename to packages/kit/test/apps/basics/src/routes/load/fetch-request-headers.json/+server.js index 28608043de70..d58d91263242 100644 --- a/packages/kit/test/apps/basics/src/routes/load/fetch-headers.json/+server.js +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers.json/+server.js @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; -/** @type {import('@sveltejs/kit').RequestHandler} */ +/** @type {import('./$types').RequestHandler} */ export function GET({ request }) { /** @type {Record} */ const body = {}; diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-headers/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers/+page.js similarity index 67% rename from packages/kit/test/apps/basics/src/routes/load/fetch-headers/+page.js rename to packages/kit/test/apps/basics/src/routes/load/fetch-request-headers/+page.js index 65f31059ef45..69e85a35b484 100644 --- a/packages/kit/test/apps/basics/src/routes/load/fetch-headers/+page.js +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers/+page.js @@ -1,6 +1,6 @@ /** @type {import('@sveltejs/kit').Load} */ export async function load({ fetch }) { - const res = await fetch('/load/fetch-headers.json'); + const res = await fetch('/load/fetch-request-headers.json'); return { headers: await res.json() diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-headers/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-request-headers/+page.svelte similarity index 100% rename from packages/kit/test/apps/basics/src/routes/load/fetch-headers/+page.svelte rename to packages/kit/test/apps/basics/src/routes/load/fetch-request-headers/+page.svelte diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers.json/+server.js b/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers.json/+server.js new file mode 100644 index 000000000000..f85e58c595a9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers.json/+server.js @@ -0,0 +1,8 @@ +/** @type {import('./$types').RequestHandler} */ +export function GET() { + return new Response('ok', { + headers: { + 'x-foo': 'this should not appear' + } + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers/+page.js new file mode 100644 index 000000000000..4ca808378220 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers/+page.js @@ -0,0 +1,9 @@ +/** @type {import('./$types').PageLoad} */ +export async function load({ fetch }) { + const response = await fetch('/load/fetch-response-headers.json'); + + return { + message: await response.text(), + foo: response.headers.get('x-foo') + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers/+page.svelte new file mode 100644 index 000000000000..93a90eb83100 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-response-headers/+page.svelte @@ -0,0 +1,7 @@ + + +

{data.message}

+

{data.foo}

diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 373423798a10..f9fd21b196bc 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -682,8 +682,7 @@ test.describe('Load', () => { // by the time JS has run, hydration will have nuked these scripts const script_contents = await page.innerHTML('script[data-sveltekit-fetched]'); - const payload = - '{"status":200,"statusText":"","headers":{"content-type":"application/json"},"body":"{\\"answer\\":42}"}'; + const payload = '{"status":200,"statusText":"","headers":{},"body":"{\\"answer\\":42}"}'; expect(script_contents).toBe(payload); } @@ -701,11 +700,8 @@ test.describe('Load', () => { expect(await page.textContent('h1')).toBe('a: X'); expect(await page.textContent('h2')).toBe('b: Y'); - const payload_a = - '{"status":200,"statusText":"","headers":{"content-type":"text/plain;charset=UTF-8"},"body":"X"}'; - - const payload_b = - '{"status":200,"statusText":"","headers":{"content-type":"text/plain;charset=UTF-8"},"body":"Y"}'; + const payload_a = '{"status":200,"statusText":"","headers":{},"body":"X"}'; + const payload_b = '{"status":200,"statusText":"","headers":{},"body":"Y"}'; if (!javaScriptEnabled) { // by the time JS has run, hydration will have nuked these scripts @@ -829,7 +825,7 @@ test.describe('Load', () => { browserName }) => { await page.goto('/load'); - await clicknav('[href="/load/fetch-headers"]'); + await clicknav('[href="/load/fetch-request-headers"]'); const json = /** @type {string} */ (await page.textContent('pre')); const headers = JSON.parse(json); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index cade25ac832c..f6d0e4ba8d25 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -273,6 +273,7 @@ export interface RequestHandler< export interface ResolveOptions { transformPageChunk?: (input: { html: string; done: boolean }) => MaybePromise; + filterSerializedResponseHeaders?: (name: string, value: string) => boolean; } export class Server {