Repository: sveltejs/kit
Pull request #6507 (2022-09-01 13:17:17)

fix server manifest generation
I was seeing this in the CI logs:

<img width="1006" alt="image" src="https://user-images.githubusercontent.com/1162160/187923208-9fbc9444-1623-456c-af96-6ce9759b9657.png">

It's happening because `JSON.stringify` turns `undefined` to `null` inside an array, unhelpfully. Bit weird that it isn't actually causing anything to fail, but I figured it was worth fixing anyway

Rich-Harris: losing my hair trying to understand these test failures
changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 7bf657410510e578975ccc5b3ce511a6566eb644

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name          | Type  |
| ------------- | ----- |
| @sveltejs/kit | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/sveltejs/kit/new/fix-server-manifest?filename=.changeset/strong-chicken-nail.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A---%0A%0Afix%20server%20manifest%20generation%0A)



Patch:
diff --git a/.changeset/fresh-brooms-relate.md b/.changeset/fresh-brooms-relate.md
new file mode 100644
index 000000000..0724963fe
--- /dev/null
+++ b/.changeset/fresh-brooms-relate.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Fix server manifest generation
diff --git a/.changeset/nice-poets-taste.md b/.changeset/nice-poets-taste.md
new file mode 100644
index 000000000..3d2a6475d
--- /dev/null
+++ b/.changeset/nice-poets-taste.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Silently skip prefetching of external URLs when using `data-sveltekit-prefetch`. Warn like before when calling `prefetch()` for external URLs.
diff --git a/.changeset/pre.json b/.changeset/pre.json
index f472fcf46..deedcc65c 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -849,6 +849,7 @@
     "rude-starfishes-press",
     "rude-tips-rule",
     "rude-tomatoes-thank",
+    "selfish-ads-kick",
     "selfish-avocados-develop",
     "selfish-crews-count",
     "selfish-dancers-happen",
@@ -859,6 +860,7 @@
     "selfish-walls-grow",
     "serious-bears-listen",
     "serious-books-push",
+    "serious-bottles-knock",
     "serious-dolphins-approve",
     "serious-ducks-exercise",
     "serious-elephants-dress",
@@ -1020,6 +1022,7 @@
     "stale-humans-cough",
     "stale-mirrors-attend",
     "stale-plums-heal",
+    "strange-apples-vanish",
     "strange-hounds-invite",
     "strange-kiwis-tell",
     "strange-poems-design",
diff --git a/.changeset/selfish-ads-kick.md b/.changeset/selfish-ads-kick.md
new file mode 100644
index 000000000..a32883c83
--- /dev/null
+++ b/.changeset/selfish-ads-kick.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+fix peer dependency warning on vite-3.1.0-beta.1
diff --git a/.changeset/serious-bottles-knock.md b/.changeset/serious-bottles-knock.md
new file mode 100644
index 000000000..93bb036b5
--- /dev/null
+++ b/.changeset/serious-bottles-knock.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] respect cache-control max-age on the client for initially fetched responses
diff --git a/.changeset/strange-apples-vanish.md b/.changeset/strange-apples-vanish.md
new file mode 100644
index 000000000..ff1256d8c
--- /dev/null
+++ b/.changeset/strange-apples-vanish.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] block cross-site form POSTs by default. disable with config.kit.csrf.checkOrigin
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5691cd5e5..1120e690d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,6 +9,8 @@ on:
 env:
   TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
   TURBO_TEAM: svelte
+  # we call `pnpm playwright install` instead
+  PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
 
 # cancel in-progress runs on new commits to same PR (gitub.event.number)
 concurrency:
@@ -18,9 +20,6 @@ concurrency:
 jobs:
   Lint:
     runs-on: ubuntu-latest
-    env:
-      # not needed for linting
-      PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
     steps:
       - uses: actions/checkout@v3
       - uses: pnpm/action-setup@v2.2.2
@@ -46,14 +45,12 @@ jobs:
             e2e-browser: 'firefox'
           - node-version: 16
             os: macOS-latest
-            e2e-browser: 'safari'
+            e2e-browser: 'webkit'
           - node-version: 18
             os: ubuntu-latest
             e2e-browser: 'chromium'
     env:
       TURBO_CACHE_KEY: ${{ matrix.os }}-${{ matrix.node-version }}
-      # Install playwright's binray under node_modules so it will be cached together
-      PLAYWRIGHT_BROWSERS_PATH: '0'
       KIT_E2E_BROWSER: ${{matrix.e2e-browser}}
     steps:
       - run: git config --global core.autocrlf false
@@ -64,7 +61,7 @@ jobs:
           node-version: ${{ matrix.node-version }}
           cache: pnpm
       - run: pnpm install --frozen-lockfile
-      - run: pnpm playwright install
+      - run: pnpm playwright install ${{ matrix.e2e-browser }}
       - run: pnpm test
       - name: Archive test results
         if: failure()
diff --git a/documentation/docs/14-configuration.md b/documentation/docs/14-configuration.md
index 1893883bb..6fe48cd98 100644
--- a/documentation/docs/14-configuration.md
+++ b/documentation/docs/14-configuration.md
@@ -25,6 +25,9 @@ const config = {
 				// ...
 			}
 		},
+		csrf: {
+			checkOrigin: true
+		},
 		env: {
 			dir: process.cwd(),
 			publicPrefix: 'PUBLIC_'
@@ -161,6 +164,14 @@ When pages are prerendered, the CSP header is added via a `<meta http-equiv>` ta
 
 > Note that most [Svelte transitions](https://svelte.dev/tutorial/transition) work by creating an inline `<style>` element. If you use these in your app, you must either leave the `style-src` directive unspecified or add `unsafe-inline`.
 
+### csrf
+
+Protection against [cross-site request forgery](https://owasp.org/www-community/attacks/csrf) attacks:
+
+- `checkOrigin` — if `true`, SvelteKit will check the incoming `origin` header for `POST` form submissions and verify that it matches the server's origin
+
+To allow people to make `POST` form submissions to your app from other origins, you will need to disable this option. Be careful!
+
 ### env
 
 Environment variable configuration:
diff --git a/packages/adapter-static/package.json b/packages/adapter-static/package.json
index cf797a1b6..33dee37a6 100644
--- a/packages/adapter-static/package.json
+++ b/packages/adapter-static/package.json
@@ -27,7 +27,6 @@
 	"devDependencies": {
 		"@sveltejs/kit": "workspace:*",
 		"@types/node": "^16.11.36",
-		"playwright-chromium": "^1.25.0",
 		"sirv": "^2.0.2",
 		"svelte": "^3.48.0",
 		"typescript": "^4.8.2",
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 215781d90..24580aca2 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,19 @@
 # @sveltejs/kit
 
+## 1.0.0-next.463
+
+### Patch Changes
+
+- fix peer dependency warning on vite-3.1.0-beta.1 ([#6512](https://github.com/sveltejs/kit/pull/6512))
+
+* [breaking] respect cache-control max-age on the client for initially fetched responses ([#6461](https://github.com/sveltejs/kit/pull/6461))
+
+## 1.0.0-next.462
+
+### Patch Changes
+
+- [breaking] block cross-site form POSTs by default. disable with config.kit.csrf.checkOrigin ([#6510](https://github.com/sveltejs/kit/pull/6510))
+
 ## 1.0.0-next.461
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index cb92c3f7f..c9e0bc59b 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.0-next.461",
+	"version": "1.0.0-next.463",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
@@ -10,7 +10,7 @@
 	"homepage": "https://kit.svelte.dev",
 	"type": "module",
 	"dependencies": {
-		"@sveltejs/vite-plugin-svelte": "^1.0.3",
+		"@sveltejs/vite-plugin-svelte": "^1.0.4",
 		"cookie": "^0.5.0",
 		"devalue": "^3.1.2",
 		"kleur": "^4.1.4",
diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js
index c324dd347..4ece16a53 100644
--- a/packages/kit/src/core/config/index.spec.js
+++ b/packages/kit/src/core/config/index.spec.js
@@ -72,6 +72,9 @@ const get_defaults = (prefix = '') => ({
 			directives: directive_defaults,
 			reportOnly: directive_defaults
 		},
+		csrf: {
+			checkOrigin: true
+		},
 		endpointExtensions: undefined,
 		env: {
 			dir: process.cwd(),
diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js
index 9b4eefd5c..a442fa1d7 100644
--- a/packages/kit/src/core/config/options.js
+++ b/packages/kit/src/core/config/options.js
@@ -125,6 +125,10 @@ const options = object(
 				reportOnly: directives
 			}),
 
+			csrf: object({
+				checkOrigin: boolean(true)
+			}),
+
 			// TODO: remove this for the 1.0 release
 			endpointExtensions: error(
 				(keypath) => `${keypath} has been renamed to config.kit.moduleExtensions`
diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js
index 8f4529cb9..d0e35d59f 100644
--- a/packages/kit/src/core/generate_manifest/index.js
+++ b/packages/kit/src/core/generate_manifest/index.js
@@ -60,13 +60,13 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 					if (!route.page && !route.endpoint) return;
 
 					return `{
-						id: ${s(route.id)},
-						pattern: ${route.pattern},
-						names: ${s(route.names)},
-						types: ${s(route.types)},
-						page: ${s(route.page)},
-						endpoint: ${route.endpoint ? loader(`${relative_path}/${build_data.server.vite_manifest[route.endpoint.file].file}`) : 'null'}
-					}`;
+					id: ${s(route.id)},
+					pattern: ${route.pattern},
+					names: ${s(route.names)},
+					types: ${s(route.types)},
+					page: ${route.page ? `{ layouts: ${get_nodes(route.page.layouts)}, errors: ${get_nodes(route.page.errors)}, leaf: ${route.page.leaf} }` : 'null'},
+					endpoint: ${route.endpoint ? loader(`${relative_path}/${build_data.server.vite_manifest[route.endpoint.file].file}`) : 'null'}
+				}`;
 				}).filter(Boolean).join(',\n\t\t\t\t')}
 			],
 			matchers: async () => {
@@ -76,3 +76,17 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 		}
 	}`.replace(/^\t/gm, '');
 }
+
+/** @param {Array<number | undefined>} indexes */
+function get_nodes(indexes) {
+	let string = indexes.map((n) => n ?? '').join(',');
+
+	if (indexes.at(-1) === undefined) {
+		// since JavaScript ignores trailing commas, we need to insert a dummy
+		// comma so that the array has the correct length if the last item
+		// is undefined
+		string += ',';
+	}
+
+	return `[${string}]`;
+}
diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js
index 231243312..137d358ee 100644
--- a/packages/kit/src/exports/vite/build/build_server.js
+++ b/packages/kit/src/exports/vite/build/build_server.js
@@ -54,6 +54,9 @@ export class Server {
 	constructor(manifest) {
 		this.options = {
 			csp: ${s(config.kit.csp)},
+			csrf: {
+				check_origin: ${s(config.kit.csrf.checkOrigin)},
+			},
 			dev: false,
 			get_stack: error => String(error), // for security
 			handle_error: (error, event) => {
diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js
index cc6b0bd23..4ce5b8fb9 100644
--- a/packages/kit/src/exports/vite/dev/index.js
+++ b/packages/kit/src/exports/vite/dev/index.js
@@ -377,6 +377,9 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) {
 					request,
 					{
 						csp: svelte_config.kit.csp,
+						csrf: {
+							check_origin: svelte_config.kit.csrf.checkOrigin
+						},
 						dev: true,
 						get_stack: (error) => fix_stack_trace(error),
 						handle_error: (error, event) => {
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index 190348e84..b8b7e03c9 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -2,7 +2,7 @@ import { onMount, tick } from 'svelte';
 import { normalize_error } from '../../utils/error.js';
 import { make_trackable, decode_params, normalize_path } from '../../utils/url.js';
 import { find_anchor, get_base_uri, scroll_state } from './utils.js';
-import { lock_fetch, unlock_fetch, initial_fetch, native_fetch } from './fetcher.js';
+import { lock_fetch, unlock_fetch, initial_fetch, subsequent_fetch } from './fetcher.js';
 import { parse } from './parse.js';
 import { error } from '../../exports/index.js';
 
@@ -584,11 +584,13 @@ export function create_client({ target, base, trailing_slash }) {
 					}
 
 					// we must fixup relative urls so they are resolved from the target page
-					const normalized = new URL(requested, url).href;
-					depends(normalized);
+					const resolved = new URL(requested, url).href;
+					depends(resolved);
 
-					// prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be normalized
-					return started ? native_fetch(normalized, init) : initial_fetch(requested, init);
+					// prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be resolved
+					return started
+						? subsequent_fetch(resolved, init)
+						: initial_fetch(requested, resolved, init);
 				},
 				setHeaders: () => {}, // noop
 				depends,
@@ -941,7 +943,7 @@ export function create_client({ target, base, trailing_slash }) {
 
 	/** @param {URL} url */
 	function get_navigation_intent(url) {
-		if (url.origin !== location.origin || !url.pathname.startsWith(base)) return;
+		if (is_external_url(url)) return;
 
 		const path = decodeURI(url.pathname.slice(base.length) || '/');
 
@@ -960,6 +962,11 @@ export function create_client({ target, base, trailing_slash }) {
 		}
 	}
 
+	/** @param {URL} url */
+	function is_external_url(url) {
+		return url.origin !== location.origin || !url.pathname.startsWith(base);
+	}
+
 	/**
 	 * @param {{
 	 *   url: URL;
@@ -1154,6 +1161,7 @@ export function create_client({ target, base, trailing_slash }) {
 			const trigger_prefetch = (event) => {
 				const { url, options } = find_anchor(event);
 				if (url && options.prefetch === '') {
+					if (is_external_url(url)) return;
 					prefetch(url);
 				}
 			};
diff --git a/packages/kit/src/runtime/client/fetcher.js b/packages/kit/src/runtime/client/fetcher.js
index 97ee337dc..1552656fa 100644
--- a/packages/kit/src/runtime/client/fetcher.js
+++ b/packages/kit/src/runtime/client/fetcher.js
@@ -2,7 +2,7 @@ import { hash } from '../hash.js';
 
 let loading = 0;
 
-export const native_fetch = window.fetch;
+const native_fetch = window.fetch;
 
 export function lock_fetch() {
 	loading += 1;
@@ -33,15 +33,40 @@ if (import.meta.env.DEV) {
 			);
 		}
 
+		const method = input instanceof Request ? input.method : init?.method || 'GET';
+
+		if (method !== 'GET') {
+			const url = new URL(input instanceof Request ? input.url : input.toString(), document.baseURI)
+				.href;
+			cache.delete(url);
+		}
+
+		return native_fetch(input, init);
+	};
+} else {
+	window.fetch = (input, init) => {
+		const method = input instanceof Request ? input.method : init?.method || 'GET';
+
+		if (method !== 'GET') {
+			const url = new URL(input instanceof Request ? input.url : input.toString(), document.baseURI)
+				.href;
+			cache.delete(url);
+		}
+
 		return native_fetch(input, init);
 	};
 }
 
+const cache = new Map();
+
 /**
+ * Should be called on the initial run of load functions that hydrate the page.
+ * Saves any requests with cache-control max-age to the cache.
  * @param {RequestInfo} resource
+ * @param {string} resolved
  * @param {RequestInit} [opts]
  */
-export function initial_fetch(resource, opts) {
+export function initial_fetch(resource, resolved, opts) {
 	const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url);
 
 	let selector = `script[data-sveltekit-fetched][data-url=${url}]`;
@@ -51,10 +76,32 @@ export function initial_fetch(resource, opts) {
 	}
 
 	const script = document.querySelector(selector);
-	if (script && script.textContent) {
+	if (script?.textContent) {
 		const { body, ...init } = JSON.parse(script.textContent);
+
+		const ttl = script.getAttribute('data-ttl');
+		if (ttl) cache.set(resolved, { body, init, ttl: 1000 * Number(ttl) });
+
 		return Promise.resolve(new Response(body, init));
 	}
 
 	return native_fetch(resource, opts);
 }
+
+/**
+ * Tries to get the response from the cache, if max-age allows it, else does a fetch.
+ * @param {string} resolved
+ * @param {RequestInit} [opts]
+ */
+export function subsequent_fetch(resolved, opts) {
+	const cached = cache.get(resolved);
+	if (cached) {
+		if (performance.now() < cached.ttl) {
+			return new Response(cached.body, cached.init);
+		}
+
+		cache.delete(resolved);
+	}
+
+	return native_fetch(resolved, opts);
+}
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index 413903640..ec142c6ec 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -18,6 +18,21 @@ const default_transform = ({ html }) => html;
 export async function respond(request, options, state) {
 	let url = new URL(request.url);
 
+	if (options.csrf.check_origin) {
+		const type = request.headers.get('content-type')?.split(';')[0];
+
+		const forbidden =
+			request.method === 'POST' &&
+			request.headers.get('origin') !== url.origin &&
+			(type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data');
+
+		if (forbidden) {
+			return new Response(`Cross-site ${request.method} form submissions are forbidden`, {
+				status: 403
+			});
+		}
+	}
+
 	const { parameter, allowed } = options.method_override;
 	const method_override = url.searchParams.get(parameter)?.toUpperCase();
 
diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js
index adde5f994..f67ca95b6 100644
--- a/packages/kit/src/runtime/server/page/fetch.js
+++ b/packages/kit/src/runtime/server/page/fetch.js
@@ -214,6 +214,7 @@ export function create_fetch({ event, options, state, route, prerender_default }
 
 						fetched.push({
 							url: requested,
+							method: opts.method || 'GET',
 							body: opts.body,
 							response: {
 								status: status_number,
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 9e69c3dcd..ec03e0712 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -284,7 +284,7 @@ export async function render_response({
 	}
 
 	if (page_config.ssr && page_config.csr) {
-		body += `\n\t${fetched.map(serialize_data).join('\n\t')}`;
+		body += `\n\t${fetched.map((item) => serialize_data(item, !!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 f32922bb5..24a86a260 100644
--- a/packages/kit/src/runtime/server/page/serialize_data.js
+++ b/packages/kit/src/runtime/server/page/serialize_data.js
@@ -35,10 +35,11 @@ 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 {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) {
+export function serialize_data(fetched, prerendering = false) {
 	const safe_payload = JSON.stringify(fetched.response).replace(
 		pattern,
 		(match) => replacements[match]
@@ -54,5 +55,18 @@ export function serialize_data(fetched) {
 		attrs.push(`data-hash=${escape_html_attr(hash(fetched.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}"`);
+			}
+		}
+	}
+
 	return `<script ${attrs.join(' ')}>${safe_payload}</script>`;
 }
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 9906ba429..3383c7f98 100644
--- a/packages/kit/src/runtime/server/page/serialize_data.spec.js
+++ b/packages/kit/src/runtime/server/page/serialize_data.spec.js
@@ -6,6 +6,7 @@ test('escapes slashes', () => {
 	assert.equal(
 		serialize_data({
 			url: 'foo',
+			method: 'GET',
 			body: null,
 			response: {
 				status: 200,
@@ -24,6 +25,7 @@ test('escapes exclamation marks', () => {
 	assert.equal(
 		serialize_data({
 			url: 'foo',
+			method: 'GET',
 			body: null,
 			response: {
 				status: 200,
@@ -44,6 +46,7 @@ test('escapes the attribute values', () => {
 	assert.equal(
 		serialize_data({
 			url: raw,
+			method: 'GET',
 			body: null,
 			response: {
 				status: 200,
diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts
index e67401369..4da2b3d18 100644
--- a/packages/kit/src/runtime/server/page/types.d.ts
+++ b/packages/kit/src/runtime/server/page/types.d.ts
@@ -3,6 +3,7 @@ import { HttpError } from '../../control.js';
 
 export interface Fetched {
 	url: string;
+	method: string;
 	body?: string | null;
 	response: {
 		status: number;
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index 0dca0aa9f..5f1b79da0 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -88,6 +88,7 @@ declare module '$app/environment' {
  * 	disableScrollHandling,
  * 	goto,
  * 	invalidate,
+ * 	invalidateAll,
  * 	prefetch,
  * 	prefetchRoutes
  * } from '$app/navigation';
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index ffe84ca00..cbbd7fb73 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -128,6 +128,9 @@ export interface KitConfig {
 		directives?: CspDirectives;
 		reportOnly?: CspDirectives;
 	};
+	csrf?: {
+		checkOrigin?: boolean;
+	};
 	env?: {
 		dir?: string;
 		publicPrefix?: string;
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 234af54f8..508a7bb6e 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -290,6 +290,9 @@ export type SSRNodeLoader = () => Promise<SSRNode>;
 
 export interface SSROptions {
 	csp: ValidatedConfig['kit']['csp'];
+	csrf: {
+		check_origin: boolean;
+	};
 	dev: boolean;
 	get_stack: (error: Error) => string | undefined;
 	handle_error(error: Error & { frame?: string }, event: RequestEvent): void;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6d729cd68..133fc2ad0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -147,7 +147,6 @@ importers:
     specifiers:
       '@sveltejs/kit': workspace:*
       '@types/node': ^16.11.36
-      playwright-chromium: ^1.25.0
       sirv: ^2.0.2
       svelte: ^3.48.0
       typescript: ^4.8.2
@@ -156,7 +155,6 @@ importers:
     devDependencies:
       '@sveltejs/kit': link:../kit
       '@types/node': 16.11.42
-      playwright-chromium: 1.25.0
       sirv: 2.0.2
       svelte: 3.48.0
       typescript: 4.8.2
@@ -272,7 +270,7 @@ importers:
   packages/kit:
     specifiers:
       '@playwright/test': ^1.25.0
-      '@sveltejs/vite-plugin-svelte': ^1.0.3
+      '@sveltejs/vite-plugin-svelte': ^1.0.4
       '@types/connect': ^3.4.35
       '@types/cookie': ^0.5.1
       '@types/marked': ^4.0.3
@@ -299,7 +297,7 @@ importers:
       uvu: ^0.5.3
       vite: ^3.1.0-beta.1
     dependencies:
-      '@sveltejs/vite-plugin-svelte': 1.0.3_le3uwffmhe7iwfccb43atcj5xe
+      '@sveltejs/vite-plugin-svelte': 1.0.4_le3uwffmhe7iwfccb43atcj5xe
       cookie: 0.5.0
       devalue: 3.1.2
       kleur: 4.1.5
@@ -1022,13 +1020,13 @@ packages:
       golden-fleece: 1.0.9
     dev: true
 
-  /@sveltejs/vite-plugin-svelte/1.0.3_le3uwffmhe7iwfccb43atcj5xe:
-    resolution: {integrity: sha512-0Qu51m2W9RBlxWPp8i31KJpnqmjWMOne8vAzgmOX6ZM9uX+/RAv6BNhEMcNoP5MsyLjyW1ZTCiJoaZZ5EeqpFg==}
+  /@sveltejs/vite-plugin-svelte/1.0.4_le3uwffmhe7iwfccb43atcj5xe:
+    resolution: {integrity: sha512-UZco2fdj0OVuRWC0SUJjEOftITc2IeHLFJNp00ym9MuQ9dShnlO4P29G8KUxRlcS7kSpzHuko6eCR9MOALj7lQ==}
     engines: {node: ^14.18.0 || >= 16}
     peerDependencies:
       diff-match-patch: ^1.0.5
       svelte: ^3.44.0
-      vite: ^3.0.0
+      vite: ^3.0.0 || ^3.1.0-beta.1
     peerDependenciesMeta:
       diff-match-patch:
         optional: true
@@ -3277,15 +3275,6 @@ packages:
       find-up: 4.1.0
     dev: true
 
-  /playwright-chromium/1.25.0:
-    resolution: {integrity: sha512-FH9ho3noAWVStCJx4XW78+D8QW0A99WDp53DDkYeVdEpJqCmAIKHCSE6dl5XtaDKrZPYC1ZG5hGXQh1K5H/p+g==}
-    engines: {node: '>=14'}
-    hasBin: true
-    requiresBuild: true
-    dependencies:
-      playwright-core: 1.25.0
-    dev: true
-
   /playwright-core/1.25.0:
     resolution: {integrity: sha512-kZ3Jwaf3wlu0GgU0nB8UMQ+mXFTqBIFz9h1svTlNduNKjnbPXFxw7mJanLVjqxHJRn62uBfmgBj93YHidk2N5Q==}
     engines: {node: '>=14'}


Test patch:
diff --git a/packages/adapter-static/test/utils.js b/packages/adapter-static/test/utils.js
index 5f7bd122e..1ace81f46 100644
--- a/packages/adapter-static/test/utils.js
+++ b/packages/adapter-static/test/utils.js
@@ -3,9 +3,26 @@ import fs from 'fs';
 import http from 'http';
 import { fileURLToPath } from 'url';
 import sirv from 'sirv';
-import { chromium } from 'playwright-chromium';
+import { chromium, webkit, firefox } from 'playwright';
 import * as uvu from 'uvu';
 
+const known_browsers = {
+	chromium: chromium,
+	firefox: firefox,
+	webkit: webkit
+};
+const test_browser_name = /** @type {keyof typeof } */ (process.env.KIT_E2E_BROWSER ?? 'chromium');
+
+const test_browser = known_browsers[test_browser_name];
+
+if (!test_browser) {
+	throw new Error(
+		`invalid test browser specified: KIT_E2E_BROWSER=${
+			process.env.KIT_E2E_BROWSER
+		}. Allowed values: ${Object.keys().join(', ')}`
+	);
+}
+
 /**
  * @typedef {{
  *   cwd: string;
@@ -55,7 +72,7 @@ export function run(app, callback) {
 
 			context.port = port;
 			context.base = `http://localhost:${context.port}`;
-			context.browser = await chromium.launch();
+			context.browser = await test_browser.launch();
 			context.page = await context.browser.newPage();
 		} catch (e) {
 			// TODO remove unnecessary try-catch https://github.com/lukeed/uvu/pull/61
@@ -64,8 +81,20 @@ export function run(app, callback) {
 	});
 
 	suite.after(async (context) => {
-		context.server.close();
-		context.browser.close();
+		if (context.browser) {
+			try {
+				await context.browser.close();
+			} catch (e) {
+				console.error('failed to close test browser', e);
+			}
+		}
+		if (context.server) {
+			try {
+				await context.server.close();
+			} catch (e) {
+				console.error('failed to close test server', e);
+			}
+		}
 	});
 
 	callback(suite);
diff --git a/packages/kit/test/apps/basics/src/routes/csrf/+server.js b/packages/kit/test/apps/basics/src/routes/csrf/+server.js
new file mode 100644
index 000000000..cfbef1424
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/csrf/+server.js
@@ -0,0 +1,4 @@
+/** @type {import('./$types').RequestHandler} */
+export function POST() {
+	return new Response(undefined, { status: 201 });
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cache-control/+page.js b/packages/kit/test/apps/basics/src/routes/load/cache-control/+page.js
new file mode 100644
index 000000000..f17bd7a9f
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cache-control/+page.js
@@ -0,0 +1,5 @@
+/** @type {import('./$types').PageLoad} */
+export async function load({ fetch }) {
+	const resp = await fetch('/load/cache-control/count');
+	return resp.json();
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cache-control/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/cache-control/+page.svelte
new file mode 100644
index 000000000..3e97ca339
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cache-control/+page.svelte
@@ -0,0 +1,14 @@
+<script>
+	import { invalidate } from '$app/navigation';
+
+	/** @type {import('./$types').PageData} */
+	export let data;
+
+	async function fetch_again() {
+		await fetch('/load/cache-control/increment');
+		invalidate('/load/cache-control/count');
+	}
+</script>
+
+<p>Count is {data.count}</p>
+<button on:click={fetch_again}>Fetch again</button>
diff --git a/packages/kit/test/apps/basics/src/routes/load/cache-control/count/+server.js b/packages/kit/test/apps/basics/src/routes/load/cache-control/count/+server.js
new file mode 100644
index 000000000..92865ed5c
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cache-control/count/+server.js
@@ -0,0 +1,7 @@
+import { json } from '@sveltejs/kit';
+import { count } from '../state.js';
+
+export function GET({ setHeaders }) {
+	setHeaders({ 'cache-control': 'public, max-age=4', age: '2' });
+	return json({ count });
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cache-control/increment/+server.js b/packages/kit/test/apps/basics/src/routes/load/cache-control/increment/+server.js
new file mode 100644
index 000000000..1ddf1e429
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cache-control/increment/+server.js
@@ -0,0 +1,7 @@
+import { json } from '@sveltejs/kit';
+import { increment } from '../state.js';
+
+export function GET() {
+	increment();
+	return json({});
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cache-control/reset/+server.js b/packages/kit/test/apps/basics/src/routes/load/cache-control/reset/+server.js
new file mode 100644
index 000000000..77baf0c15
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cache-control/reset/+server.js
@@ -0,0 +1,7 @@
+import { json } from '@sveltejs/kit';
+import { reset } from '../state.js';
+
+export function GET() {
+	reset();
+	return json({});
+}
diff --git a/packages/kit/test/apps/basics/src/routes/load/cache-control/state.js b/packages/kit/test/apps/basics/src/routes/load/cache-control/state.js
new file mode 100644
index 000000000..ba1431d40
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/load/cache-control/state.js
@@ -0,0 +1,9 @@
+export let count = 0;
+
+export function increment() {
+	return count++;
+}
+
+export function reset() {
+	count = 0;
+}
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index 6f5c87ec4..56f873e66 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -381,6 +381,26 @@ test.describe('Load', () => {
 		expect(await page.textContent('pre')).toBe(JSON.stringify({ foo: { bar: 'Custom layout' } }));
 	});
 
+	test('load does not call fetch if max-age allows it', async ({ page, request }) => {
+		await request.get('/load/cache-control/reset');
+
+		page.addInitScript(`
+			window.now = 0;
+			window.performance.now = () => now;
+		`);
+
+		await page.goto('/load/cache-control');
+		expect(await page.textContent('p')).toBe('Count is 0');
+		await page.waitForTimeout(500);
+		await page.click('button');
+		await page.waitForTimeout(500);
+		expect(await page.textContent('p')).toBe('Count is 0');
+
+		await page.evaluate(() => (window.now = 2500));
+		await page.click('button');
+		await expect(page.locator('p')).toHaveText('Count is 2');
+	});
+
 	if (process.env.DEV) {
 		test('using window.fetch causes a warning', async ({ page }) => {
 			const port = 5173;
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index f22bcd6a4..9cf989154 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -1,5 +1,6 @@
 import { expect } from '@playwright/test';
 import { test } from '../../../utils.js';
+import { fetch } from 'undici';
 import { createHash, randomBytes } from 'node:crypto';
 
 /** @typedef {import('@playwright/test').Response} Response */
@@ -22,6 +23,20 @@ test.describe('Content-Type', () => {
 	});
 });
 
+test.describe('CSRF', () => {
+	test('Blocks requests with incorrect origin', async ({ baseURL }) => {
+		const res = await fetch(`${baseURL}/csrf`, {
+			method: 'POST',
+			headers: {
+				'content-type': 'application/x-www-form-urlencoded'
+			}
+		});
+
+		expect(res.status).toBe(403);
+		expect(await res.text()).toBe('Cross-site POST form submissions are forbidden');
+	});
+});
+
 test.describe('Endpoints', () => {
 	test('HEAD with matching headers but without body', async ({ request }) => {
 		const url = '/endpoint-output/body';
diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js
index 3e23d49bb..cf7f24889 100644
--- a/packages/kit/test/utils.js
+++ b/packages/kit/test/utils.js
@@ -115,7 +115,7 @@ export const test = base.extend({
 const known_devices = {
 	chromium: devices['Desktop Chrome'],
 	firefox: devices['Desktop Firefox'],
-	safari: devices['Desktop Safari']
+	webkit: devices['Desktop Safari']
 };
 const test_browser = /** @type {keyof typeof known_devices} */ (
 	process.env.KIT_E2E_BROWSER ?? 'chromium'