Skip to content
Merged
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/quiet-bodies-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: don't attempt to serialize fetch responses when the request body is not a string or TypedArray
44 changes: 27 additions & 17 deletions packages/kit/src/runtime/client/fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,24 @@ const cache = new Map();
export function initial_fetch(resource, opts) {
const selector = build_selector(resource, opts);

const script = document.querySelector(selector);
if (script?.textContent) {
script.remove(); // In case multiple script tags match the same selector
let { body, ...init } = JSON.parse(script.textContent);

const b64 = script.getAttribute('data-b64');
if (b64 !== null) {
// Can't use native_fetch('data:...;base64,${body}')
// csp can block the request
body = base64_decode(body);
}
if (selector) {
const script = document.querySelector(selector);
if (script?.textContent) {
script.remove(); // In case multiple script tags match the same selector
let { body, ...init } = JSON.parse(script.textContent);

const b64 = script.getAttribute('data-b64');
if (b64 !== null) {
// Can't use native_fetch('data:...;base64,${body}')
// csp can block the request
body = base64_decode(body);
}

const ttl = script.getAttribute('data-ttl');
if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });
const ttl = script.getAttribute('data-ttl');
if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });

return Promise.resolve(new Response(body, init));
return Promise.resolve(new Response(body, init));
}
}

return DEV ? dev_fetch(resource, opts) : window.fetch(resource, opts);
Expand All @@ -119,7 +121,7 @@ export function initial_fetch(resource, opts) {
export function subsequent_fetch(resource, resolved, opts) {
if (cache.size > 0) {
const selector = build_selector(resource, opts);
const cached = cache.get(selector);
const cached = selector && cache.get(selector);
if (cached) {
// https://developer.mozilla.org/en-US/docs/Web/API/Request/cache#value
if (
Expand Down Expand Up @@ -155,22 +157,30 @@ export function dev_fetch(resource, opts) {
* Build the cache key for a given request
* @param {URL | RequestInfo} resource
* @param {RequestInit} [opts]
* @returns {string | null} `null` for requests the server never serializes
*/
function build_selector(resource, opts) {
const url = JSON.stringify(resource instanceof Request ? resource.url : resource);

let selector = `script[data-sveltekit-fetched][data-url=${url}]`;

if (opts?.headers || opts?.body) {
const body = opts.body;

if (body && typeof body !== 'string' && !ArrayBuffer.isView(body)) {
// the server skips serializing these, so a matching script tag belongs to another request
return null;
}

/** @type {import('types').StrictBody[]} */
const values = [];

if (opts.headers) {
values.push([...new Headers(opts.headers)].join(','));
}

if (opts.body && (typeof opts.body === 'string' || ArrayBuffer.isView(opts.body))) {
values.push(opts.body);
if (body) {
values.push(/** @type {import('types').StrictBody} */ (body));
}

selector += `[data-hash="${hash(...values)}"]`;
Expand Down
20 changes: 15 additions & 5 deletions packages/kit/src/runtime/server/page/load_data.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,14 +334,24 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts)
);
}

const request_body =
input instanceof Request && cloned_body
? await stream_to_string(cloned_body)
: init?.body;

if (
request_body &&
typeof request_body !== 'string' &&
!ArrayBuffer.isView(request_body)
) {
// requests whose body can't be hashed aren't serialized — the browser repeats the fetch
return;
}

fetched.push({
url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
method: event.request.method,
request_body: /** @type {string | ArrayBufferView | undefined} */ (
input instanceof Request && cloned_body
? await stream_to_string(cloned_body)
: init?.body
),
request_body: /** @type {string | ArrayBufferView | null | undefined} */ (request_body),
request_headers: cloned_headers,
response_body: body,
response,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import('@sveltejs/kit').RequestHandler} */
export function GET() {
return new Response('GET');
}

/** @type {import('@sveltejs/kit').RequestHandler} */
export async function POST({ request }) {
return new Response(`POST:${await request.text()}`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @type {import('@sveltejs/kit').Load} */
export async function load({ fetch }) {
// same headers as the GET below, so both requests hash identically without their bodies
const headers = { 'x-collision': 'yes' };

const post = await fetch('/load/serialization-post-body-collision.json', {
method: 'POST',
headers,
body: new URLSearchParams('a=1')
});

const get = await fetch('/load/serialization-post-body-collision.json', { headers });

return { post: await post.text(), get: await get.text() };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
/** @type {import('./$types').PageProps} */
let { data } = $props();
</script>

<h1>{data.post}</h1>
<p>{data.get}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** @type {import('@sveltejs/kit').Load} */
export async function load({ fetch }) {
const response = await fetch('/load/serialization-post.json', {
method: 'POST',
body: new URLSearchParams('a=1')
});

return { body: await response.text() };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script>
/** @type {import('./$types').PageProps} */
let { data } = $props();
</script>

<h1>{data.body}</h1>
27 changes: 27 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,33 @@
}
});

test('POST fetches with non-string bodies are not serialized', async ({
page,
javaScriptEnabled
}) => {
await page.goto('/load/serialization-post-body-object');

expect(await page.textContent('h1')).toBe('A=1');

if (!javaScriptEnabled) {
expect(await page.locator('script[data-sveltekit-fetched]').count()).toBe(0);
}
});

test('POST fetches with non-string bodies do not reuse responses serialized for other requests', async ({
page,
javaScriptEnabled
}) => {
await page.goto('/load/serialization-post-body-collision');

expect(await page.textContent('h1')).toBe('POST:a=1');
expect(await page.textContent('p')).toBe('GET');

if (!javaScriptEnabled) {
expect(await page.locator('script[data-sveltekit-fetched]').count()).toBe(1);
}
});

test('fetches using an arraybuffer serialized with b64', async ({ page, javaScriptEnabled }) => {
await page.goto('/load/fetch-arraybuffer-b64');

Expand Down Expand Up @@ -741,7 +768,7 @@
});

test.describe('$app/env', () => {
test('includes version', async ({ page }) => {

Check warning on line 771 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit-server-side-route-resolution (build)

flaky test: includes version

retries: 2
await page.goto('/app-environment');
expect(await page.textContent('h1')).toBe('TEST_VERSION');
});
Expand Down
Loading