{"repro_id":"REPRO-2026-00363","version":6,"title":"h3 path traversal via unsanitized static file serving pathname","repro_type":"security","status":"published","severity":"high","description":"On Node.js, h3 serveStatic accepts an unnormalized percent-encoded request pathname, decodes it, and passes traversal-bearing asset IDs to getMeta/getContents, allowing unauthenticated reads outside the intended static root.","root_cause":"# Root Cause Analysis: CVE-2026-86253\n\n## Summary\n\nCVE-2026-86253 / GHSA-wr4h-v87w-p3r7 is an unauthenticated path-traversal vulnerability in h3's `serveStatic()` HTTP static-file handler. On the vulnerable Node.js path, an HTTP pathname containing percent-encoded dot segments remains unnormalized until `serveStatic()` calls `decodeURI()`. This changes `%2e%2e` into `..` and passes the resulting traversal identifier to the configured `getMeta()` and `getContents()` callbacks. When those callbacks implement h3's documented filesystem-serving pattern with `path.join()`, the resolved path escapes the static root. In this run, h3 1.15.5 disclosed a unique file placed outside the static directory in two independent HTTP attempts, while h3 1.15.6 returned HTTP 404 to the same request in two fixed negative-control attempts.\n\n## Impact\n\n- **Package/component affected:** npm package `h3`, specifically `serveStatic()` in `src/utils/static.ts` on Node.js deployments using the raw request-URL fast path.\n- **Affected versions:** `< 1.15.6` on the 1.x line and `>= 2.0.0, <= 2.0.1-rc.14` on the 2.x release-candidate line. Patched versions are 1.15.6 and 2.0.1-rc.15.\n- **Tested vulnerable target:** Published `h3@1.15.5` npm archive, SHA-256 `f99c479e7c12d0ad30c01e32fb76415a5eb8bf5f90ac0a036baf14d3f8432a7d`.\n- **Tested fixed target:** Published `h3@1.15.6` npm archive, SHA-256 `3b47bca4d3eacae8c61a6cc6a1049ec1c91c9a7cd29c710978977260a2dbe1ee`.\n- **Risk and consequences:** A remote unauthenticated requester can read files accessible to the Node service account. Potential targets include application source, `.env` files, API or database credentials, private keys, and operating-system files. The advisory rates the issue Moderate (CVSS 3.1 score 5.9), while the submitted ticket labels the operational risk High because confidentiality impact can be substantial.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Remote arbitrary-file disclosure (`info_leak`) through an h3 `serveStatic` endpoint.\n- **Reproduced impact from this run:** A real localhost HTTP requester selected `/%2e%2e/private-sentinel.txt`; vulnerable h3 decoded it to `/../private-sentinel.txt`, resolved that outside `public/`, and returned the private sentinel in an HTTP 200 response.\n- **Parity:** `full`.\n- **Not demonstrated:** Files that the service account cannot read remain inaccessible. The proof intentionally used a controlled sentinel rather than collecting real credentials or host-sensitive data. No write, code-execution, or privilege-escalation impact was claimed or tested.\n\n## Root Cause\n\nThe vulnerable statement in `src/utils/static.ts` was:\n\n```ts\nconst originalId = decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname)));\n```\n\nOn Node.js, h3 receives request pathnames through srvx's optimized URL path, which preserves percent-encoded dot segments rather than applying WHATWG URL dot-segment normalization. Consequently, the attacker input `/%2e%2e/private-sentinel.txt` reaches `serveStatic()` in encoded form. `decodeURI()` then converts `%2e%2e` into the literal segment `..`, yielding `/../private-sentinel.txt`. Vulnerable h3 performs no subsequent traversal normalization before calling the application-supplied static back end. The documented filesystem callbacks join this identifier with the configured public root, and Node's path resolution selects the sibling private file.\n\nFix commit [`0e751b4059060f2ade01a0bdfd96b0f5ffc8a26d`](https://github.com/h3js/h3/commit/0e751b4059060f2ade01a0bdfd96b0f5ffc8a26d) added `resolveDotSegments()` and applied it immediately after decoding. The script resolves the commit's vulnerable parent as `7791538e15ca22437307c06b78fa155bb73632a6`, verifies that the parent lacks this call, verifies that the fixed commit contains it, and records the exact diff at `bundle/repro/evidence/source_fix.diff`. The fix collapses traversal segments without allowing resolution above the static-root identifier.\n\nA distinct later advisory, GHSA-72gr-qfp7-vwhw, concerns a double-decoding bypass through `%252e%252e` and was fixed in 1.15.9. That follow-up is intentionally left for the required variant-analysis stage and does not change the base CVE result against the originally patched 1.15.6 control.\n\n## Reproduction Steps\n\n1. Run `bash bundle/repro/reproduction_steps.sh` from any directory. The script derives its bundle path from its own location or `PRUVA_ROOT`.\n2. The script reads `bundle/project_cache_context.json`, uses `/pruva/project-cache/repo` when the prepared cache is available, and otherwise falls back to `bundle/artifacts/h3`.\n3. It clones or reuses `https://github.com/h3js/h3`, fetches fix commit `0e751b4059060f2ade01a0bdfd96b0f5ffc8a26d`, resolves its parent, and checks the fixing hunk on both sides.\n4. It retrieves and installs the exact published h3 1.15.5 and 1.15.6 archives. The generated Node server uses the real h3 `createApp`, `eventHandler`, `serveStatic`, and `toNodeListener` code with filesystem callbacks.\n5. For each attempt, it starts a fresh bounded Node HTTP listener, verifies a legitimate `health.txt` request, then sends the raw pathname `/%2e%2e/private-sentinel.txt` using `curl --path-as-is`.\n6. It requires two vulnerable attempts to return HTTP 200 with the out-of-root sentinel and two fixed attempts to return a non-200 response without that sentinel. It exits nonzero on any mismatch.\n7. Before success, it writes `bundle/repro/runtime_manifest.json` with target/runtime identity and SHA-256 mappings for every finalized proof artifact.\n\nExpected terminal evidence includes:\n\n```text\n[+] CONFIRMED vulnerable attempt 1: HTTP 200 disclosed CVE-2026-86253-OUTSIDE-STATIC-ROOT-7e82b84d\n[+] CONFIRMED vulnerable attempt 2: HTTP 200 disclosed CVE-2026-86253-OUTSIDE-STATIC-ROOT-7e82b84d\n[+] NEGATIVE CONTROL fixed attempt 1: HTTP 404, no sentinel disclosure\n[+] NEGATIVE CONTROL fixed attempt 2: HTTP 404, no sentinel disclosure\n```\n\n## Evidence\n\n- **Runtime manifest:** `bundle/repro/runtime_manifest.json` is strict JSON and binds 22 immutable artifacts by SHA-256. It records `entrypoint_kind=endpoint`, `service_started=true`, `healthcheck_passed=true`, and `target_path_reached=true`.\n- **Attacker request:** `bundle/repro/evidence/vulnerable_attempt_1_request.txt` records `GET /%2e%2e/private-sentinel.txt HTTP/1.1` across the actual HTTP socket.\n- **Vulnerable server path:** `bundle/repro/evidence/vulnerable_attempt_1_server.log` records:\n\n  ```text\n  GET_META id=\"/../private-sentinel.txt\" resolved=\"/pruva/project-cache/package/h3-cve-2026-86253/runtime/private-sentinel.txt\"\n  GET_CONTENTS id=\"/../private-sentinel.txt\" resolved=\"/pruva/project-cache/package/h3-cve-2026-86253/runtime/private-sentinel.txt\"\n  ```\n\n  The configured static root is the sibling `runtime/public` directory, so this proves the selected file escaped that root.\n- **Vulnerable response:** `bundle/repro/evidence/vulnerable_attempt_1_response_headers.txt` records `HTTP/1.1 200 OK`; `bundle/repro/evidence/vulnerable_attempt_1_response_body.txt` contains the exact outside-root sentinel `CVE-2026-86253-OUTSIDE-STATIC-ROOT-7e82b84d`.\n- **Repeated vulnerable proof:** The independently started second endpoint has equivalent evidence in `bundle/repro/evidence/vulnerable_attempt_2_*`.\n- **Fixed negative control:** `bundle/repro/evidence/fixed_attempt_1_response_headers.txt` records `HTTP/1.1 404 Not Found`, its body does not contain the sentinel, and `bundle/repro/evidence/fixed_attempt_1_server.log` contains no traversal identifier or private-file callback. Attempt 2 independently repeats this result in `bundle/repro/evidence/fixed_attempt_2_*`.\n- **Source and target identity:** `bundle/repro/evidence/source_fix.diff` records the exact vendor fix. `bundle/repro/evidence/target_identity.txt` records package digests, source commits, Node v24.18.0, Node executable SHA-256 `41a74efb34cbde5c7632cdac0cf8bd1a14d0b8d73dc1e82755014d9a9ce70f5c`, Linux, and x86_64.\n- **Diagnostic log:** `bundle/logs/reproduction_steps.log` records the complete most recent script execution but is deliberately not hashed while an active `tee` is writing it.\n\n## Recommendations / Next Steps\n\n- Upgrade the 1.x package line to at least h3 1.15.9, not merely 1.15.6, so both the original CVE and the later double-decoding bypass are addressed. On the 2.x line, use a release that includes both hardening changes rather than a pre-fix release candidate.\n- Canonicalize the decoded URL path exactly once before any static-backend callback and reject or collapse `.` and `..` segments before filesystem or URL resolution.\n- Treat callback implementations as untrusted with respect to containment: resolve the candidate path to an absolute path and prove it lies beneath the configured root with separator-aware logic before opening it.\n- Add regression coverage for lowercase/uppercase percent-encoded dots, single- and double-encoded traversal, encoded separators, mixed separators, repeated slashes, root underflow, query strings, prefixes resembling the root, and non-filesystem static back ends.\n- Retain a fixed-version HTTP negative control in integration tests; unit testing only `resolveDotSegments()` would not cover the raw Node request-URL boundary and decoding order.\n\n## Additional Notes\n\n- **Idempotency:** The final script passed twice consecutively in this run. Each script invocation itself starts two clean vulnerable and two clean fixed listeners; thus the final evidence represents four independent service processes per invocation.\n- **Real boundary:** This is not a direct call to `serveStatic()`. The attacker-controlled bytes cross a real Node TCP/HTTP listener and flow through h3's request-event and static-serving path before filesystem access.\n- **Sanitizers:** None were used or needed; information disclosure was directly visible in the HTTP response.\n- **Limitations:** The proof targets Linux/Node and the vulnerable 1.x line. The advisory notes that runtimes supplying an already normalized WHATWG `URL` may not follow the vulnerable Node fast path. The exact follow-up `%252e%252e` bypass belongs to variant analysis.\n","cve_id":"CVE-2026-86253","cwe_id":"CWE-22","source_url":"https://github.com/h3js/h3","package":{"name":"h3js/h3","ecosystem":"github"},"reproduced_at":"2026-09-24T17:06:06.960618+00:00","duration_secs":1307.0,"tool_calls":213,"handoffs":2,"total_cost_usd":5.490923,"agent_costs":{"claim_matcher":0.015314,"judge":0.265195,"learning_policy":0.012283,"repro":1.988894,"support":0.705689,"vuln_variant":2.503548},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.015314},"judge":{"gpt-5.6-sol":0.265195},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.012283},"repro":{"gpt-5.6-sol":1.988894},"support":{"gpt-5.6-sol":0.705689},"vuln_variant":{"gpt-5.6-sol":2.503548}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-24T17:06:07.932744+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10137,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13961,"category":"reproduction_script"},{"path":"bundle/repro/evidence/fixed_attempt_1_request.txt","filename":"fixed_attempt_1_request.txt","size":87,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_1_response_body.txt","filename":"fixed_attempt_1_response_body.txt","size":38,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_1_response_headers.txt","filename":"fixed_attempt_1_response_headers.txt","size":162,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_1_server.log","filename":"fixed_attempt_1_server.log","size":279,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt_1_status.txt","filename":"fixed_attempt_1_status.txt","size":4,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_2_request.txt","filename":"fixed_attempt_2_request.txt","size":87,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_2_response_body.txt","filename":"fixed_attempt_2_response_body.txt","size":38,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_2_response_headers.txt","filename":"fixed_attempt_2_response_headers.txt","size":162,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt_2_server.log","filename":"fixed_attempt_2_server.log","size":279,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt_2_status.txt","filename":"fixed_attempt_2_status.txt","size":4,"category":"other"},{"path":"bundle/repro/evidence/source_fix.diff","filename":"source_fix.diff","size":2036,"category":"other"},{"path":"bundle/repro/evidence/target_identity.txt","filename":"target_identity.txt","size":611,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_1_request.txt","filename":"vulnerable_attempt_1_request.txt","size":87,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_1_response_body.txt","filename":"vulnerable_attempt_1_response_body.txt","size":44,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_1_response_headers.txt","filename":"vulnerable_attempt_1_response_headers.txt","size":169,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_1_server.log","filename":"vulnerable_attempt_1_server.log","size":540,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt_1_status.txt","filename":"vulnerable_attempt_1_status.txt","size":4,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_2_request.txt","filename":"vulnerable_attempt_2_request.txt","size":87,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_2_response_body.txt","filename":"vulnerable_attempt_2_response_body.txt","size":44,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_2_response_headers.txt","filename":"vulnerable_attempt_2_response_headers.txt","size":169,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt_2_server.log","filename":"vulnerable_attempt_2_server.log","size":540,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt_2_status.txt","filename":"vulnerable_attempt_2_status.txt","size":4,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":4639,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1160,"category":"other"}]}