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.
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:
/%2e%2e/%2e%2e/etc/passwddecodeURI() in static.ts converts %2e → ., producing: /../../../etc/passwdresolveDotSegments("/../../../etc/passwd") does resolve .. segments, clamping to /etc/passwdThe 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:
/%252e%252e/%252e%252e/etc/passwd/%2e%2e/%2e%2e/etc/passwddecodeURI() in static.ts: /%2e%2e/%2e%2e/etc/passwd (unchanged — decodeURI doesn't decode %2e)resolveDotSegments() fast-returns at line 56 because %2e contains no literal . character:
if (!path.includes(".")) {
return path;
}
/%2e%2e/%2e%2e/etc/passwd is passed to getMeta() and getContents() callbacks%2e%2e as .. per RFC 3986 / URL StandardThe 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.
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.
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.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.
{
"github_reviewed_at": "2026-03-20T20:50:09Z",
"github_reviewed": true,
"cwe_ids": [
"CWE-22"
],
"nvd_published_at": null,
"severity": "MODERATE"
}