Astro: Memory exhaustion DoS due to missing request body size limit in Server Islands
Description
Astro is a web framework. Prior to version 10.0.0, Astro's Server Islands POST handler buffers and parses the full request body as JSON without enforcing a size limit. Because JSON.parse() allocates a V8 heap object for every element in the input, a crafted payload of many small JSON objects achieves ~15x memory amplification (wire bytes to heap bytes), allowing a single unauthenticated request to exhaust the process heap and crash the server. The /_server-islands/[name] route is registered on all Astro SSR apps regardless of whether any component uses server:defer, and the body is parsed before the island name is validated, so any Astro SSR app with the Node standalone adapter is affected. This issue has been patched in version 10.0.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@astrojs/nodenpm | < 10.0.0 | 10.0.0 |
Affected products
1Patches
1f9ee8685dd26Add security.serverIslandBodySizeLimit and shared body-reading utility (#15755)
13 files changed · +231 −49
.changeset/harden-server-islands-body-limit.md+17 −0 added@@ -0,0 +1,17 @@ +--- +'astro': minor +--- + +Adds a new `security.serverIslandBodySizeLimit` configuration option + +Server island POST endpoints now enforce a body size limit, similar to the existing `security.actionBodySizeLimit` for Actions. The new option defaults to `1048576` (1 MB) and can be configured independently. + +Requests exceeding the limit are rejected with a 413 response. You can customize the limit in your Astro config: + +```js +export default defineConfig({ + security: { + serverIslandBodySizeLimit: 2097152, // 2 MB + }, +}) +```
packages/astro/src/actions/runtime/server.ts+29 −46 modified@@ -10,6 +10,7 @@ import { } from '../../core/errors/errors-data.js'; import { AstroError } from '../../core/errors/errors.js'; import { removeTrailingForwardSlash } from '../../core/path.js'; +import { BodySizeLimitError, readBodyWithLimit } from '../../core/request-body.js'; import type { APIContext } from '../../types/public/index.js'; import { ACTION_QUERY_PARAMS, ACTION_RPC_ROUTE_PATTERN } from '../consts.js'; import { @@ -267,26 +268,36 @@ async function parseRequestBody(request: Request, bodySizeLimit: number) { message: `Request body exceeds ${bodySizeLimit} bytes`, }); } - if (hasContentType(contentType, formContentTypes)) { - if (!hasContentLength) { - const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit); - const formRequest = new Request(request.url, { - method: request.method, - headers: request.headers, - body: toArrayBuffer(body), - }); - return await formRequest.formData(); + try { + if (hasContentType(contentType, formContentTypes)) { + if (!hasContentLength) { + const body = await readBodyWithLimit(request.clone(), bodySizeLimit); + const formRequest = new Request(request.url, { + method: request.method, + headers: request.headers, + body: toArrayBuffer(body), + }); + return await formRequest.formData(); + } + return await request.clone().formData(); } - return await request.clone().formData(); - } - if (hasContentType(contentType, ['application/json'])) { - if (contentLength === 0) return undefined; - if (!hasContentLength) { - const body = await readRequestBodyWithLimit(request.clone(), bodySizeLimit); - if (body.byteLength === 0) return undefined; - return JSON.parse(new TextDecoder().decode(body)); + if (hasContentType(contentType, ['application/json'])) { + if (contentLength === 0) return undefined; + if (!hasContentLength) { + const body = await readBodyWithLimit(request.clone(), bodySizeLimit); + if (body.byteLength === 0) return undefined; + return JSON.parse(new TextDecoder().decode(body)); + } + return await request.clone().json(); } - return await request.clone().json(); + } catch (e) { + if (e instanceof BodySizeLimitError) { + throw new ActionError({ + code: 'CONTENT_TOO_LARGE', + message: `Request body exceeds ${bodySizeLimit} bytes`, + }); + } + throw e; } throw new TypeError('Unsupported content type'); } @@ -471,34 +482,6 @@ export function serializeActionResult(res: SafeResult<any, any>): SerializedActi body, }; } -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);
packages/astro/src/container/index.ts+1 −0 modified@@ -166,6 +166,7 @@ function createManifest( checkOrigin: false, allowedDomains: manifest?.allowedDomains ?? [], actionBodySizeLimit: 1024 * 1024, + serverIslandBodySizeLimit: 1024 * 1024, middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), csp: manifest?.csp,
packages/astro/src/core/app/types.ts+1 −0 modified@@ -117,6 +117,7 @@ export type SSRManifest = { checkOrigin: boolean; allowedDomains?: Partial<RemotePattern>[]; actionBodySizeLimit: number; + serverIslandBodySizeLimit: number; sessionConfig?: SSRManifestSession; cacheConfig?: SSRManifestCache; cacheDir: URL;
packages/astro/src/core/build/plugins/plugin-manifest.ts+4 −0 modified@@ -345,6 +345,10 @@ async function buildManifest( settings.config.security?.actionBodySizeLimit && settings.buildOutput === 'server' ? settings.config.security.actionBodySizeLimit : 1024 * 1024, + serverIslandBodySizeLimit: + settings.config.security?.serverIslandBodySizeLimit && settings.buildOutput === 'server' + ? settings.config.security.serverIslandBodySizeLimit + : 1024 * 1024, allowedDomains: settings.config.security?.allowedDomains, key: encodedKey, sessionConfig: sessionConfigToManifest(settings.config.session),
packages/astro/src/core/config/schemas/base.ts+5 −0 modified@@ -95,6 +95,7 @@ export const ASTRO_CONFIG_DEFAULTS = { allowedDomains: [], csp: false, actionBodySizeLimit: 1024 * 1024, + serverIslandBodySizeLimit: 1024 * 1024, }, env: { schema: {}, @@ -445,6 +446,10 @@ export const AstroConfigSchema = z.object({ .number() .optional() .default(ASTRO_CONFIG_DEFAULTS.security.actionBodySizeLimit), + serverIslandBodySizeLimit: z + .number() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.security.serverIslandBodySizeLimit), csp: z .union([ z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.security.csp),
packages/astro/src/core/request-body.ts+54 −0 added@@ -0,0 +1,54 @@ +/** + * Shared utility for reading request bodies with a size limit. + * Used by both Actions and Server Islands to enforce `security.actionBodySizeLimit` + * and `security.serverIslandBodySizeLimit` respectively. + */ + +/** + * Read the request body as a `Uint8Array`, enforcing a maximum size limit. + * Checks the `Content-Length` header for early rejection, then streams the body + * and tracks bytes received. + * + * @throws {BodySizeLimitError} if the body exceeds the configured limit + */ +export async function readBodyWithLimit(request: Request, limit: number): Promise<Uint8Array> { + const contentLengthHeader = request.headers.get('content-length'); + if (contentLengthHeader) { + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength) && contentLength > limit) { + throw new BodySizeLimitError(limit); + } + } + + 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 BodySizeLimitError(limit); + } + 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; +} + +export class BodySizeLimitError extends Error { + limit: number; + constructor(limit: number) { + super(`Request body exceeds the configured limit of ${limit} bytes`); + this.name = 'BodySizeLimitError'; + this.limit = limit; + } +}
packages/astro/src/core/server-islands/endpoint.ts+16 −3 modified@@ -9,6 +9,7 @@ import { createSlotValueFromString } from '../../runtime/server/render/slot.js'; import type { ComponentInstance, RoutesList } from '../../types/astro.js'; import type { RouteData, SSRManifest } from '../../types/public/internal.js'; import { decryptString } from '../encryption.js'; +import { BodySizeLimitError, readBodyWithLimit } from '../request-body.js'; import { getPattern } from '../routing/pattern.js'; export const SERVER_ISLAND_ROUTE = '/_server-islands/[name]'; @@ -54,7 +55,12 @@ function badRequest(reason: string) { }); } -export async function getRequestData(request: Request): Promise<Response | RenderOptions> { +const DEFAULT_BODY_SIZE_LIMIT = 1024 * 1024; // 1MB + +export async function getRequestData( + request: Request, + bodySizeLimit: number = DEFAULT_BODY_SIZE_LIMIT, +): Promise<Response | RenderOptions> { switch (request.method) { case 'GET': { const url = new URL(request.url); @@ -73,7 +79,8 @@ export async function getRequestData(request: Request): Promise<Response | Rende } case 'POST': { try { - const raw = await request.text(); + const body = await readBodyWithLimit(request, bodySizeLimit); + const raw = new TextDecoder().decode(body); const data = JSON.parse(raw); // Validate that slots is not plaintext @@ -90,6 +97,12 @@ export async function getRequestData(request: Request): Promise<Response | Rende return data as RenderOptions; } catch (e) { + if (e instanceof BodySizeLimitError) { + return new Response(null, { + status: 413, + statusText: e.message, + }); + } if (e instanceof SyntaxError) { return badRequest('Request format is invalid.'); } @@ -115,7 +128,7 @@ export function createEndpoint(manifest: SSRManifest) { const componentId = params.name; // Get the request data from the body or search params - const data = await getRequestData(result.request); + const data = await getRequestData(result.request, manifest.serverIslandBodySizeLimit); // probably error if (data instanceof Response) { return data;
packages/astro/src/manifest/serialized.ts+3 −0 modified@@ -179,6 +179,9 @@ async function createSerializedManifest(settings: AstroSettings): Promise<Serial actionBodySizeLimit: settings.config.security?.actionBodySizeLimit ? settings.config.security.actionBodySizeLimit : 1024 * 1024, // 1mb default + serverIslandBodySizeLimit: settings.config.security?.serverIslandBodySizeLimit + ? settings.config.security.serverIslandBodySizeLimit + : 1024 * 1024, // 1mb default key: await encodeKey(hasEnvironmentKey() ? await getEnvironmentKey() : await createKey()), sessionConfig: sessionConfigToManifest(settings.config.session), cacheConfig: cacheConfigToManifest(
packages/astro/src/types/public/config.ts+25 −0 modified@@ -685,6 +685,31 @@ export interface AstroUserConfig< */ actionBodySizeLimit?: number; + /** + * @docs + * @name security.serverIslandBodySizeLimit + * @kind h4 + * @type {number} + * @default `1048576` (1 MB) + * @version 6.0.0 + * @description + * + * Sets the maximum size in bytes allowed for server island request bodies, which contain the encrypted props and slot HTML passed to the island component. + * + * By default, server island request bodies are limited to 1 MB (1048576 bytes) to prevent abuse. + * You can increase this limit if your server islands need to accept larger payloads. + * + * ```js + * // astro.config.mjs + * export default defineConfig({ + * security: { + * serverIslandBodySizeLimit: 10 * 1024 * 1024 // 10 MB + * } + * }) + * ``` + */ + serverIslandBodySizeLimit?: number; + /** * @docs * @name security.csp
packages/astro/src/vite-plugin-astro-server/plugin.ts+3 −0 modified@@ -203,6 +203,9 @@ export async function createDevelopmentManifest(settings: AstroSettings): Promis actionBodySizeLimit: settings.config.security?.actionBodySizeLimit ? settings.config.security.actionBodySizeLimit : 1024 * 1024, // 1mb default + serverIslandBodySizeLimit: settings.config.security?.serverIslandBodySizeLimit + ? settings.config.security.serverIslandBodySizeLimit + : 1024 * 1024, // 1mb default key: hasEnvironmentKey() ? getEnvironmentKey() : createKey(), middleware() { return {
packages/astro/test/units/app/test-helpers.js+1 −0 modified@@ -47,6 +47,7 @@ export function createManifest({ checkOrigin: false, allowedDomains: undefined, actionBodySizeLimit: 0, + serverIslandBodySizeLimit: 1024 * 1024, sessionConfig: undefined, cacheDir: rootDir, srcDir: rootDir,
packages/astro/test/units/server-islands/endpoint.test.js+72 −0 modified@@ -149,6 +149,78 @@ describe('getRequestData', () => { }); // #endregion + // #region Body size limiting + describe('POST body size limiting', () => { + it('returns 413 when POST body exceeds the configured limit', async () => { + const limit = 100; // 100 bytes + // Create a body larger than the limit + const largeBody = JSON.stringify({ + encryptedComponentExport: 'x'.repeat(200), + encryptedProps: '', + encryptedSlots: '', + }); + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: largeBody, + }); + const result = await getRequestData(req, limit); + assert.ok(result instanceof Response, 'should return a Response'); + assert.equal(result.status, 413); + }); + + it('returns 413 when Content-Length header exceeds the configured limit', async () => { + const limit = 100; // 100 bytes + const smallBody = JSON.stringify({ + encryptedComponentExport: 'enc', + encryptedProps: '', + encryptedSlots: '', + }); + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': '999999', + }, + body: smallBody, + }); + const result = await getRequestData(req, limit); + assert.ok(result instanceof Response, 'should return a Response'); + assert.equal(result.status, 413); + }); + + it('accepts POST body within the configured limit', async () => { + const limit = 10000; // 10KB + const body = { + encryptedComponentExport: 'encExport', + encryptedProps: 'encProps', + encryptedSlots: 'encSlots', + }; + const req = new Request('http://localhost/_server-islands/Island', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const result = await getRequestData(req, limit); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedComponentExport, 'encExport'); + }); + + it('uses default limit when no limit is specified', async () => { + // This should work fine with the default 1MB limit + const body = { + encryptedComponentExport: 'encExport', + encryptedProps: 'encProps', + encryptedSlots: 'encSlots', + }; + const req = makePostRequest(body); + const result = await getRequestData(req); + assert.ok(!(result instanceof Response), 'should not return a Response'); + assert.equal(result.encryptedComponentExport, 'encExport'); + }); + }); + // #endregion + // #region Unsupported HTTP methods describe('unsupported HTTP methods', () => { for (const method of ['PUT', 'DELETE', 'PATCH', 'HEAD']) {
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
5- github.com/advisories/GHSA-3rmj-9m5h-8fpvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-29772ghsaADVISORY
- github.com/withastro/astro/commit/f9ee8685dd26e9afeba3b48d41ad6714f624b12fghsaWEB
- github.com/withastro/astro/releases/tag/@astrojs/node@10.0.0ghsaWEB
- github.com/withastro/astro/security/advisories/GHSA-3rmj-9m5h-8fpvghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.