GHSA-Q5PR-72PQ-83V3

Vulnerability from github – Published: 2026-03-23 21:44 – Updated: 2026-03-23 21:49
VLAI
Summary
H3: Unbounded Chunked Cookie Count in Session Cleanup Loop may Lead to Denial of Service
Details

Summary

The 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.

Details

The 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.

The vulnerability is in getChunkedCookieCount() at src/utils/cookie.ts:244-249:

function getChunkedCookieCount(cookie: string | undefined): number {
  if (!cookie?.startsWith(CHUNKED_COOKIE)) {
    return Number.NaN;
  }
  return Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
  // No upper bound check — attacker controls this value
}

This value is consumed without validation in the cleanup loop of setChunkedCookie() at src/utils/cookie.ts:182-190:

const previousCookie = getCookie(event, name); // reads from request headers
if (previousCookie?.startsWith(CHUNKED_COOKIE)) {
  const previousChunkCount = getChunkedCookieCount(previousCookie);
  if (previousChunkCount > chunkCount) {
    for (let i = chunkCount; i <= previousChunkCount; i++) {
      deleteCookie(event, chunkCookieName(name, i), options);
      // Each deleteCookie → setCookie → scans ALL existing set-cookie headers
    }
  }
}

The same issue exists in deleteChunkedCookie() at src/utils/cookie.ts:227-232:

const chunksCount = getChunkedCookieCount(mainCookie);
if (chunksCount >= 0) {
  for (let i = 0; i < chunksCount; i++) {
    deleteCookie(event, chunkCookieName(name, i + 1), serializeOptions);
  }
}

The exploit chain through sessions:

  1. Attacker sends Cookie: h3=__chunked__999999 to any session-using endpoint
  2. getSession() (src/utils/session.ts:83) calls getChunkedCookie(event, "h3") (line 124)
  3. getChunkedCookie() returns undefined — the early return at line 153 fires because no actual chunk cookies (e.g., h3.1) exist in the request
  4. Since sealedSession is undefined, session.id remains empty (line 140), triggering updateSession() (line 143)
  5. updateSession() calls setChunkedCookie() with the newly sealed session value (line 179)
  6. Inside setChunkedCookie(), getCookie(event, name) re-reads the original request cookie __chunked__999999 at line 182
  7. previousChunkCount = 999999, chunkCount = 1 (new sealed session is small)
  8. The cleanup loop runs 999,998 iterations, each calling deleteCookie()setCookie()
  9. Each setCookie() call reads ALL existing set-cookie response headers via getSetCookie() (line 91) and iterates through them for deduplication (lines 100-106)
  10. This creates O(n²) complexity — approximately 10¹² operations for n=999999

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.

PoC

Prerequisites: An h3 application with any endpoint using getSession() or useSession().

Example minimal server:

import { H3 } from "h3";
import { getSession } from "h3";

const app = new H3();

app.get("/dashboard", async (event) => {
  const session = await getSession(event, {
    password: "my-secret-password-at-least-32-chars-long!",
  });
  return { user: session.data.user || "anonymous" };
});

export default app;

Attack (single request, no authentication):

# This single request will hang the server process
curl -H 'Cookie: h3=__chunked__999999' http://localhost:3000/dashboard

For a less extreme but still impactful test:

# ~100K iterations — will take several seconds and block all other requests
curl -H 'Cookie: h3=__chunked__100000' http://localhost:3000/dashboard

The deleteChunkedCookie() path is exploitable via clearSession():

app.post("/logout", async (event) => {
  await clearSession(event, {
    password: "my-secret-password-at-least-32-chars-long!",
  });
  return { ok: true };
});
curl -X POST -H 'Cookie: h3=__chunked__999999' http://localhost:3000/logout

Impact

  • 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.
  • No authentication required: The attack only requires the ability to send HTTP requests with a crafted cookie header.
  • Minimal attacker effort: The payload is trivially small (Cookie: h3=__chunked__999999), making it easy to automate or repeat.
  • Wide attack surface: Any endpoint in the application that uses getSession(), useSession(), or clearSession() is vulnerable. Session usage is extremely common in web applications.
  • Amplification: The ratio of attacker input (27 bytes) to server work (billions of operations) is extreme.

Recommended Fix

Add a maximum chunk count constant and validate in getChunkedCookieCount():

const MAX_CHUNKED_COOKIE_COUNT = 100;

function getChunkedCookieCount(cookie: string | undefined): number {
  if (!cookie?.startsWith(CHUNKED_COOKIE)) {
    return Number.NaN;
  }
  const count = Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
  if (Number.isNaN(count) || count < 0 || count > MAX_CHUNKED_COOKIE_COUNT) {
    return Number.NaN;
  }
  return count;
}

This 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.

Additionally, 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "h3"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.0.0-beta.4"
            },
            {
              "fixed": "2.0.1-rc.18"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-400"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-23T21:44:55Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "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\u00b2) 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 \u2014 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 \u003e chunkCount) {\n    for (let i = chunkCount; i \u003c= previousChunkCount; i++) {\n      deleteCookie(event, chunkCookieName(name, i), options);\n      // Each deleteCookie \u2192 setCookie \u2192 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 \u003e= 0) {\n  for (let i = 0; i \u003c 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` \u2014 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()` \u2192 `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\u00b2) complexity \u2014 approximately 10\u00b9\u00b2 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) =\u003e {\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 \u0027Cookie: h3=__chunked__999999\u0027 http://localhost:3000/dashboard\n```\n\nFor a less extreme but still impactful test:\n\n```bash\n# ~100K iterations \u2014 will take several seconds and block all other requests\ncurl -H \u0027Cookie: h3=__chunked__100000\u0027 http://localhost:3000/dashboard\n```\n\nThe `deleteChunkedCookie()` path is exploitable via `clearSession()`:\n\n```typescript\napp.post(\"/logout\", async (event) =\u003e {\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 \u0027Cookie: h3=__chunked__999999\u0027 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 \u003c 0 || count \u003e 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 \u003e chunkCount` is false, so the loop won\u0027t execute. The `deleteChunkedCookie()` loop also handles it since `NaN \u003e= 0` is false.",
  "id": "GHSA-q5pr-72pq-83v3",
  "modified": "2026-03-23T21:49:19Z",
  "published": "2026-03-23T21:44:55Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/h3js/h3/security/advisories/GHSA-q5pr-72pq-83v3"
    },
    {
      "type": "WEB",
      "url": "https://github.com/h3js/h3/commit/399257cb406fbeda313d88c1e288a15124fc82af"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/h3js/h3"
    },
    {
      "type": "WEB",
      "url": "https://github.com/h3js/h3/releases/tag/v2.0.1-rc.18"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
      "type": "CVSS_V3"
    }
  ],
  "summary": "H3: Unbounded Chunked Cookie Count in Session Cleanup Loop may Lead to Denial of Service"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…