Astro has memory exhaustion DoS due to missing request body size limit in Server Actions
Description
Astro is a web framework. In versions 9.0.0 through 9.5.3, Astro server actions have no default request body size limit, which can lead to memory exhaustion DoS. A single large POST to a valid action endpoint can crash the server process on memory-constrained deployments. On-demand rendered sites built with Astro can define server actions, which automatically parse incoming request bodies (JSON or FormData). The body is buffered entirely into memory with no size limit — a single oversized request is sufficient to exhaust the process heap and crash the server. Astro's Node adapter (mode: 'standalone') creates an HTTP server with no body size protection. In containerized environments, the crashed process is automatically restarted, and repeated requests cause a persistent crash-restart loop. Action names are discoverable from HTML form attributes on any public page, so no authentication is required. The vulnerability allows unauthenticated denial of service against SSR standalone deployments using server actions. A single oversized request crashes the server process, and repeated requests cause a persistent crash-restart loop in containerized environments. Version 9.5.4 contains a fix.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@astrojs/nodenpm | >= 9.0.0, < 9.5.4 | 9.5.4 |
Affected products
1Patches
1522f880b07a4Limit action request body size (#15564)
3 files changed · +111 −2
.changeset/giant-bananas-sit.md+6 −0 added@@ -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.
packages/astro/src/actions/runtime/server.ts+64 −2 modified@@ -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<Uint8Array> { + 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; +}
packages/astro/test/actions.test.js+41 −0 modified@@ -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');
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-jm64-8m5q-4qh8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27729ghsaADVISORY
- github.com/withastro/astro/commit/522f880b07a4ea7d69a19b5507fb53a5ed6c87f8ghsax_refsource_MISCWEB
- github.com/withastro/astro/pull/15564ghsax_refsource_MISCWEB
- github.com/withastro/astro/releases/tag/%40astrojs%2Fnode%409.5.4mitrex_refsource_MISC
- github.com/withastro/astro/releases/tag/@astrojs/node@9.5.4ghsaWEB
- github.com/withastro/astro/security/advisories/GHSA-jm64-8m5q-4qh8ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.