diff --git a/.changeset/giant-bananas-sit.md b/.changeset/giant-bananas-sit.md new file mode 100644 index 000000000000..f310f9982fc5 --- /dev/null +++ b/.changeset/giant-bananas-sit.md @@ -0,0 +1,6 @@ +--- +'astro': patch +'@astrojs/node': patch +--- + +Add a default body size limit for server actions to prevent oversized requests from exhausting memory. diff --git a/packages/astro/src/actions/runtime/server.ts b/packages/astro/src/actions/runtime/server.ts index b7c2daeffca2..7cca3aeac17a 100644 --- a/packages/astro/src/actions/runtime/server.ts +++ b/packages/astro/src/actions/runtime/server.ts @@ -327,6 +327,9 @@ export function getActionContext(context: APIContext): AstroActionContext { try { input = await parseRequestBody(context.request); } catch (e) { + if (e instanceof ActionError) { + return { data: undefined, error: e }; + } if (e instanceof TypeError) { return { data: undefined, error: new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE' }) }; } @@ -378,16 +381,75 @@ function getCallerInfo(ctx: APIContext) { return undefined; } +const DEFAULT_ACTION_BODY_SIZE_LIMIT = 1024 * 1024; + async function parseRequestBody(request: Request) { const contentType = request.headers.get('content-type'); - const contentLength = request.headers.get('Content-Length'); + const contentLengthHeader = request.headers.get('content-length'); + const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : undefined; + const hasContentLength = typeof contentLength === 'number' && Number.isFinite(contentLength); if (!contentType) return undefined; + if (hasContentLength && contentLength > DEFAULT_ACTION_BODY_SIZE_LIMIT) { + throw new ActionError({ + code: 'CONTENT_TOO_LARGE', + message: `Request body exceeds ${DEFAULT_ACTION_BODY_SIZE_LIMIT} bytes`, + }); + } if (hasContentType(contentType, formContentTypes)) { + if (!hasContentLength) { + const body = await readRequestBodyWithLimit(request.clone(), DEFAULT_ACTION_BODY_SIZE_LIMIT); + const formRequest = new Request(request.url, { + method: request.method, + headers: request.headers, + body: toArrayBuffer(body), + }); + return await formRequest.formData(); + } return await request.clone().formData(); } if (hasContentType(contentType, ['application/json'])) { - return contentLength === '0' ? undefined : await request.clone().json(); + if (contentLength === 0) return undefined; + if (!hasContentLength) { + const body = await readRequestBodyWithLimit(request.clone(), DEFAULT_ACTION_BODY_SIZE_LIMIT); + if (body.byteLength === 0) return undefined; + return JSON.parse(new TextDecoder().decode(body)); + } + return await request.clone().json(); } throw new TypeError('Unsupported content type'); } + +async function readRequestBodyWithLimit(request: Request, limit: number): Promise { + if (!request.body) return new Uint8Array(); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + received += value.byteLength; + if (received > limit) { + throw new ActionError({ + code: 'CONTENT_TOO_LARGE', + message: `Request body exceeds ${limit} bytes`, + }); + } + chunks.push(value); + } + } + const buffer = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + return buffer; +} + +function toArrayBuffer(buffer: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(buffer.byteLength); + copy.set(buffer); + return copy.buffer; +} diff --git a/packages/astro/test/actions.test.js b/packages/astro/test/actions.test.js index 7471ebcd0961..60f95bcaa212 100644 --- a/packages/astro/test/actions.test.js +++ b/packages/astro/test/actions.test.js @@ -66,6 +66,26 @@ describe('Astro Actions', () => { assert.equal(data.subscribeButtonState, 'smashed'); }); + it('Rejects oversized JSON action body', async () => { + const largeActionPayload = JSON.stringify({ + channel: 'a'.repeat(2 * 1024 * 1024), + }); + const res = await fixture.fetch('/_actions/subscribe', { + method: 'POST', + body: largeActionPayload, + headers: { + 'Content-Type': 'application/json', + }, + }); + + assert.equal(res.ok, false); + assert.equal(res.status, 413); + assert.equal(res.headers.get('Content-Type'), 'application/json'); + + const data = await res.json(); + assert.equal(data.code, 'CONTENT_TOO_LARGE'); + }); + it('Exposes comment action', async () => { const formData = new FormData(); formData.append('channel', 'bholmesdev'); @@ -179,6 +199,27 @@ describe('Astro Actions', () => { assert.equal(data.subscribeButtonState, 'smashed'); }); + it('Rejects oversized JSON action body', async () => { + const largeActionPayload = JSON.stringify({ + channel: 'a'.repeat(2 * 1024 * 1024), + }); + const req = new Request('http://example.com/_actions/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: largeActionPayload, + }); + const res = await app.render(req); + + assert.equal(res.ok, false); + assert.equal(res.status, 413); + assert.equal(res.headers.get('Content-Type'), 'application/json'); + + const data = await res.json(); + assert.equal(data.code, 'CONTENT_TOO_LARGE'); + }); + it('Exposes comment action', async () => { const formData = new FormData(); formData.append('channel', 'bholmesdev');