Skip to content

Commit 522f880

Browse files
authored
Limit action request body size (#15564)
* Limit the size of an action payload Limits the size to prevent exhausting the server and potentionally crashing it. * fix build
1 parent 436962a commit 522f880

3 files changed

Lines changed: 111 additions & 2 deletions

File tree

.changeset/giant-bananas-sit.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'astro': patch
3+
'@astrojs/node': patch
4+
---
5+
6+
Add a default body size limit for server actions to prevent oversized requests from exhausting memory.

packages/astro/src/actions/runtime/server.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ export function getActionContext(context: APIContext): AstroActionContext {
327327
try {
328328
input = await parseRequestBody(context.request);
329329
} catch (e) {
330+
if (e instanceof ActionError) {
331+
return { data: undefined, error: e };
332+
}
330333
if (e instanceof TypeError) {
331334
return { data: undefined, error: new ActionError({ code: 'UNSUPPORTED_MEDIA_TYPE' }) };
332335
}
@@ -378,16 +381,75 @@ function getCallerInfo(ctx: APIContext) {
378381
return undefined;
379382
}
380383

384+
const DEFAULT_ACTION_BODY_SIZE_LIMIT = 1024 * 1024;
385+
381386
async function parseRequestBody(request: Request) {
382387
const contentType = request.headers.get('content-type');
383-
const contentLength = request.headers.get('Content-Length');
388+
const contentLengthHeader = request.headers.get('content-length');
389+
const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : undefined;
390+
const hasContentLength = typeof contentLength === 'number' && Number.isFinite(contentLength);
384391

385392
if (!contentType) return undefined;
393+
if (hasContentLength && contentLength > DEFAULT_ACTION_BODY_SIZE_LIMIT) {
394+
throw new ActionError({
395+
code: 'CONTENT_TOO_LARGE',
396+
message: `Request body exceeds ${DEFAULT_ACTION_BODY_SIZE_LIMIT} bytes`,
397+
});
398+
}
386399
if (hasContentType(contentType, formContentTypes)) {
400+
if (!hasContentLength) {
401+
const body = await readRequestBodyWithLimit(request.clone(), DEFAULT_ACTION_BODY_SIZE_LIMIT);
402+
const formRequest = new Request(request.url, {
403+
method: request.method,
404+
headers: request.headers,
405+
body: toArrayBuffer(body),
406+
});
407+
return await formRequest.formData();
408+
}
387409
return await request.clone().formData();
388410
}
389411
if (hasContentType(contentType, ['application/json'])) {
390-
return contentLength === '0' ? undefined : await request.clone().json();
412+
if (contentLength === 0) return undefined;
413+
if (!hasContentLength) {
414+
const body = await readRequestBodyWithLimit(request.clone(), DEFAULT_ACTION_BODY_SIZE_LIMIT);
415+
if (body.byteLength === 0) return undefined;
416+
return JSON.parse(new TextDecoder().decode(body));
417+
}
418+
return await request.clone().json();
391419
}
392420
throw new TypeError('Unsupported content type');
393421
}
422+
423+
async function readRequestBodyWithLimit(request: Request, limit: number): Promise<Uint8Array> {
424+
if (!request.body) return new Uint8Array();
425+
const reader = request.body.getReader();
426+
const chunks: Uint8Array[] = [];
427+
let received = 0;
428+
while (true) {
429+
const { done, value } = await reader.read();
430+
if (done) break;
431+
if (value) {
432+
received += value.byteLength;
433+
if (received > limit) {
434+
throw new ActionError({
435+
code: 'CONTENT_TOO_LARGE',
436+
message: `Request body exceeds ${limit} bytes`,
437+
});
438+
}
439+
chunks.push(value);
440+
}
441+
}
442+
const buffer = new Uint8Array(received);
443+
let offset = 0;
444+
for (const chunk of chunks) {
445+
buffer.set(chunk, offset);
446+
offset += chunk.byteLength;
447+
}
448+
return buffer;
449+
}
450+
451+
function toArrayBuffer(buffer: Uint8Array): ArrayBuffer {
452+
const copy = new Uint8Array(buffer.byteLength);
453+
copy.set(buffer);
454+
return copy.buffer;
455+
}

packages/astro/test/actions.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ describe('Astro Actions', () => {
6666
assert.equal(data.subscribeButtonState, 'smashed');
6767
});
6868

69+
it('Rejects oversized JSON action body', async () => {
70+
const largeActionPayload = JSON.stringify({
71+
channel: 'a'.repeat(2 * 1024 * 1024),
72+
});
73+
const res = await fixture.fetch('/_actions/subscribe', {
74+
method: 'POST',
75+
body: largeActionPayload,
76+
headers: {
77+
'Content-Type': 'application/json',
78+
},
79+
});
80+
81+
assert.equal(res.ok, false);
82+
assert.equal(res.status, 413);
83+
assert.equal(res.headers.get('Content-Type'), 'application/json');
84+
85+
const data = await res.json();
86+
assert.equal(data.code, 'CONTENT_TOO_LARGE');
87+
});
88+
6989
it('Exposes comment action', async () => {
7090
const formData = new FormData();
7191
formData.append('channel', 'bholmesdev');
@@ -179,6 +199,27 @@ describe('Astro Actions', () => {
179199
assert.equal(data.subscribeButtonState, 'smashed');
180200
});
181201

202+
it('Rejects oversized JSON action body', async () => {
203+
const largeActionPayload = JSON.stringify({
204+
channel: 'a'.repeat(2 * 1024 * 1024),
205+
});
206+
const req = new Request('http://example.com/_actions/subscribe', {
207+
method: 'POST',
208+
headers: {
209+
'Content-Type': 'application/json',
210+
},
211+
body: largeActionPayload,
212+
});
213+
const res = await app.render(req);
214+
215+
assert.equal(res.ok, false);
216+
assert.equal(res.status, 413);
217+
assert.equal(res.headers.get('Content-Type'), 'application/json');
218+
219+
const data = await res.json();
220+
assert.equal(data.code, 'CONTENT_TOO_LARGE');
221+
});
222+
182223
it('Exposes comment action', async () => {
183224
const formData = new FormData();
184225
formData.append('channel', 'bholmesdev');

0 commit comments

Comments
 (0)