diff --git a/.changeset/hot-shoes-bow.md b/.changeset/hot-shoes-bow.md new file mode 100644 index 000000000000..5a3a6794aef1 --- /dev/null +++ b/.changeset/hot-shoes-bow.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Fix parsing content-type header for actions diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 6f08b62f1dcc..8eb14c6da13f 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -3,6 +3,7 @@ import { render_page } from './page/index.js'; import { render_response } from './page/render.js'; import { respond_with_error } from './page/respond_with_error.js'; import { coalesce_to_error } from '../../utils/error.js'; +import { is_form_content_type } from '../../utils/http.js'; import { GENERIC_ERROR, handle_fatal_error } from './utils.js'; import { decode_params, disable_search, normalize_path } from '../../utils/url.js'; import { exec } from '../../utils/routing.js'; @@ -24,12 +25,10 @@ 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'); + is_form_content_type(request); if (forbidden) { return new Response(`Cross-site ${request.method} form submissions are forbidden`, { diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 6f9507e87279..02c038d5e814 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -1,6 +1,6 @@ import { error, json } from '../../../exports/index.js'; import { normalize_error } from '../../../utils/error.js'; -import { negotiate } from '../../../utils/http.js'; +import { is_form_content_type, negotiate } from '../../../utils/http.js'; import { HttpError, Redirect, ValidationError } from '../../control.js'; import { handle_error_and_jsonify } from '../utils.js'; @@ -180,9 +180,10 @@ export async function call_action(event, actions) { throw new Error(`No action with name '${name}' found`); } - const type = event.request.headers.get('content-type')?.split('; ')[0]; - if (type !== 'application/x-www-form-urlencoded' && type !== 'multipart/form-data') { - throw new Error(`Actions expect form-encoded data (received ${type})`); + if (!is_form_content_type(event.request)) { + throw new Error( + `Actions expect form-encoded data (received ${event.request.headers.get('content-type')}` + ); } return action(event); diff --git a/packages/kit/src/utils/http.js b/packages/kit/src/utils/http.js index 434a0f1f869f..31721d10cc4a 100644 --- a/packages/kit/src/utils/http.js +++ b/packages/kit/src/utils/http.js @@ -53,3 +53,20 @@ export function negotiate(accept, types) { return accepted; } + +/** + * Returns `true` if the request contains a `content-type` header with the given type + * @param {Request} request + * @param {...string} types + */ +export function is_content_type(request, ...types) { + const type = request.headers.get('content-type')?.split(';', 1)[0].trim() ?? ''; + return types.includes(type); +} + +/** + * @param {Request} request + */ +export function is_form_content_type(request) { + return is_content_type(request, 'application/x-www-form-urlencoded', 'multipart/form-data'); +} diff --git a/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte b/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte index e3699100eeba..e784d2b206b0 100644 --- a/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte @@ -2,10 +2,12 @@ /** @type {import('./$types').ActionData} */ export let form; - async function submit() { + async function submit({ submitter }) { const res = await fetch(this.action, { method: 'POST', - body: new FormData(this), + body: submitter.getAttribute('formenctype') === 'multipart/form-data' + ? new FormData(this) + : new URLSearchParams({ username: this['username'].value }), headers: { accept: 'application/json' } @@ -19,5 +21,6 @@
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 93816939bd17..4522ee30b63f 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1773,7 +1773,7 @@ test.describe('Actions', () => { } }); - test('Success data is returned', async ({ page }) => { + test('Success data as form-data is returned', async ({ page }) => { await page.goto('/actions/success-data'); expect(await page.textContent('pre')).toBe(JSON.stringify(null)); @@ -1781,12 +1781,26 @@ test.describe('Actions', () => { await page.type('input[name="username"]', 'foo'); await Promise.all([ page.waitForRequest((request) => request.url().includes('/actions/success-data')), - page.click('button') + page.click('button[formenctype="multipart/form-data"]') ]); await expect(page.locator('pre')).toHaveText(JSON.stringify({ result: 'foo' })); }); + test('Success data as form-urlencoded is returned', async ({ page }) => { + await page.goto('/actions/success-data'); + + expect(await page.textContent('pre')).toBe(JSON.stringify(null)); + + await page.type('input[name="username"]', 'bar'); + await Promise.all([ + page.waitForRequest((request) => request.url().includes('/actions/success-data')), + page.click('button[formenctype="application/x-www-form-urlencoded"]') + ]); + + await expect(page.locator('pre')).toHaveText(JSON.stringify({ result: 'bar' })); + }); + test('applyAction updates form prop', async ({ page, javaScriptEnabled }) => { await page.goto('/actions/update-form'); expect(await page.textContent('pre')).toBe(JSON.stringify(null));