diff --git a/.changeset/kind-ducks-vanish.md b/.changeset/kind-ducks-vanish.md new file mode 100644 index 000000000000..331274d51100 --- /dev/null +++ b/.changeset/kind-ducks-vanish.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[breaking] only include specified headers in server-side fetch requests and serialized responses diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 4ece16a5316c..849e6ecb9e4a 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -80,6 +80,10 @@ const get_defaults = (prefix = '') => ({ dir: process.cwd(), publicPrefix: 'PUBLIC_' }, + fetch: { + forwardedRequestHeaders: [], + serializedResponseHeaders: ['content-type'] + }, files: { assets: join(prefix, 'static'), hooks: join(prefix, 'src/hooks'), diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index a442fa1d7451..7635d24ec67b 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -139,6 +139,11 @@ const options = object( publicPrefix: string('PUBLIC_') }), + fetch: object({ + forwardedRequestHeaders: string_array([]), + serializedResponseHeaders: string_array(['content-type']) + }), + files: object({ assets: string('static'), hooks: string(join('src', 'hooks')), diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index 137d358eeb75..abbe855b3ffa 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -58,6 +58,10 @@ export class Server { check_origin: ${s(config.kit.csrf.checkOrigin)}, }, dev: false, + fetch: { + forwarded_request_headers: ${s(config.kit.fetch.forwardedRequestHeaders)}, + serialized_response_headers: ${s(config.kit.fetch.serializedResponseHeaders)} + }, get_stack: error => String(error), // for security handle_error: (error, event) => { this.options.hooks.handleError({ diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 4ce5b8fb9b27..ffc4750f4a43 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -381,6 +381,10 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { check_origin: svelte_config.kit.csrf.checkOrigin }, dev: true, + fetch: { + forwarded_request_headers: svelte_config.kit.fetch.forwardedRequestHeaders, + serialized_response_headers: svelte_config.kit.fetch.serializedResponseHeaders + }, get_stack: (error) => fix_stack_trace(error), handle_error: (error, event) => { hooks.handleError({ diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js index f67ca95b6b3d..243ab9de61db 100644 --- a/packages/kit/src/runtime/server/page/fetch.js +++ b/packages/kit/src/runtime/server/page/fetch.js @@ -49,17 +49,11 @@ export function create_fetch({ event, options, state, route, prerender_default } opts.headers = new Headers(opts.headers); // merge headers from request - for (const [key, value] of event.request.headers) { - if ( - key !== 'authorization' && - key !== 'connection' && - key !== 'content-length' && - key !== 'cookie' && - key !== 'host' && - key !== 'if-none-match' && - !opts.headers.has(key) - ) { - opts.headers.set(key, value); + for (const header of options.fetch.forwarded_request_headers) { + const value = event.request.headers.get(header); + + if (value !== null && !opts.headers.has(header)) { + opts.headers.set(header, value); } } @@ -192,34 +186,15 @@ 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 (!opts.body || typeof opts.body === 'string') { - const status_number = Number(response.status); - if (isNaN(status_number)) { - throw new Error( - `response.status is not a number. value: "${ - response.status - }" type: ${typeof response.status}` - ); - } - fetched.push({ url: requested, method: opts.method || 'GET', body: opts.body, response: { - status: status_number, + status: response.status, statusText: response.statusText, - headers, + headers: response.headers, body } }); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index ec03e07122dd..143e8f725e79 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, options.fetch.serialized_response_headers, !!state.prerendering) + ) + .join('\n\t')}`; } if (options.service_worker) { diff --git a/packages/kit/src/runtime/server/page/serialize_data.js b/packages/kit/src/runtime/server/page/serialize_data.js index 24a86a260b53..6b4250ab4cee 100644 --- a/packages/kit/src/runtime/server/page/serialize_data.js +++ b/packages/kit/src/runtime/server/page/serialize_data.js @@ -35,15 +35,29 @@ 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 {string[]} serialized_response_headers * @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, serialized_response_headers, prerendering = false) { + /** @type {Record} */ + const headers = {}; + + for (const header of serialized_response_headers) { + const value = fetched.response.headers.get(header); + + if (value !== null) { + headers[header] = value; + } + } + + const safe_payload = JSON.stringify({ + status: fetched.response.status, + statusText: fetched.response.statusText, + headers, + body: fetched.response.body + }).replace(pattern, (match) => replacements[match]); const attrs = [ 'type="application/json"', @@ -56,11 +70,11 @@ export function serialize_data(fetched, prerendering = false) { } if (!prerendering && fetched.method === 'GET') { - const cache_control = /** @type {string} */ (fetched.response.headers['cache-control']); + const cache_control = /** @type {string} */ (fetched.response.headers.get('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 age = /** @type {string} */ (fetched.response.headers.get('age')) ?? '0'; const ttl = +match[1] - +age; 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..62a2926a92a1 100644 --- a/packages/kit/src/runtime/server/page/serialize_data.spec.js +++ b/packages/kit/src/runtime/server/page/serialize_data.spec.js @@ -4,17 +4,20 @@ import { serialize_data } from './serialize_data.js'; test('escapes slashes', () => { assert.equal( - serialize_data({ - url: 'foo', - method: 'GET', - body: null, - response: { - status: 200, - statusText: 'OK', - headers: {}, - body: '' @@ -23,17 +26,20 @@ test('escapes slashes', () => { test('escapes exclamation marks', () => { 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', + body: null, + response: { + status: 200, + statusText: 'OK', + headers: new Headers(), + body: 'alert("xss")' + } + }, + [] + ), '' @@ -44,17 +50,20 @@ test('escapes the attribute values', () => { const raw = 'an "attr" & a \ud800'; const escaped = 'an "attr" & a �'; assert.equal( - serialize_data({ - url: raw, - method: 'GET', - body: null, - response: { - status: 200, - statusText: 'OK', - headers: {}, - body: '' - } - }), + serialize_data( + { + url: raw, + method: 'GET', + body: null, + response: { + status: 200, + statusText: 'OK', + headers: new Headers(), + body: '' + } + }, + [] + ), `` ); }); diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 4da2b3d18171..1f4436db0289 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -1,4 +1,4 @@ -import { ResponseHeaders, SSRNode, CspDirectives } from 'types'; +import { SSRNode, CspDirectives } from 'types'; import { HttpError } from '../../control.js'; export interface Fetched { @@ -8,7 +8,7 @@ export interface Fetched { response: { status: number; statusText: string; - headers: ResponseHeaders; + headers: Headers; body: string; }; } diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 6e9eb56521ae..07f77560901c 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -818,18 +818,20 @@ test.describe('Load', () => { const json = /** @type {string} */ (await page.textContent('pre')); const headers = JSON.parse(json); - expect(headers).toEqual({ - // the referer will be the previous page in the client-side - // navigation case - referer: `${baseURL}/load`, - // these headers aren't particularly useful, but they allow us to verify - // that page headers are being forwarded - 'sec-fetch-dest': - browserName === 'webkit' ? undefined : javaScriptEnabled ? 'empty' : 'document', - 'sec-fetch-mode': - browserName === 'webkit' ? undefined : javaScriptEnabled ? 'cors' : 'navigate', - connection: javaScriptEnabled ? 'keep-alive' : undefined - }); + if (javaScriptEnabled) { + expect(headers).toEqual({ + // the referer will be the previous page in the client-side + // navigation case + referer: `${baseURL}/load`, + // these headers aren't particularly useful, but they allow us to verify + // that page headers are being forwarded + 'sec-fetch-dest': browserName === 'webkit' ? undefined : 'empty', + 'sec-fetch-mode': browserName === 'webkit' ? undefined : 'cors', + connection: 'keep-alive' + }); + } else { + expect(headers).toEqual({}); + } }); test('exposes rawBody to endpoints', async ({ page, clicknav }) => { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index cbbd7fb734a9..f7f84f9fe23e 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -135,7 +135,10 @@ export interface KitConfig { dir?: string; publicPrefix?: string; }; - moduleExtensions?: string[]; + fetch?: { + forwardedRequestHeaders?: string[]; + serializedResponseHeaders?: string[]; + }; files?: { assets?: string; hooks?: string; @@ -151,6 +154,7 @@ export interface KitConfig { parameter?: string; allowed?: string[]; }; + moduleExtensions?: string[]; outDir?: string; paths?: { assets?: string; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 508a7bb6ee51..e9d116548416 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -294,6 +294,10 @@ export interface SSROptions { check_origin: boolean; }; dev: boolean; + fetch: { + forwarded_request_headers: string[]; + serialized_response_headers: string[]; + }; get_stack: (error: Error) => string | undefined; handle_error(error: Error & { frame?: string }, event: RequestEvent): void; hooks: Hooks;