GHSA-FP4X-GGRF-WMC6
Vulnerability from github – Published: 2026-03-23 21:48 – Updated: 2026-03-23 21:48Summary
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:
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:
new URL("http://target.com//evil.com/path").origin→"http://target.com"— origin check passesnew URL("http://target.com//evil.com/path").pathname→"//evil.com/path"— extracted as redirect location- Browser receives
Location: //evil.com/path→ interprets as protocol-relative URL → redirects toevil.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
# 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:
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);
}
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "h3"
},
"ranges": [
{
"events": [
{
"introduced": "2.0.1-rc.17"
},
{
"fixed": "2.0.1-rc.18"
}
],
"type": "ECOSYSTEM"
}
],
"versions": [
"2.0.1-rc.17"
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-601"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-23T21:48:24Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nThe `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.\n\n## Details\n\nThe vulnerable code is in `src/utils/response.ts:89-97`:\n\n```typescript\nexport function redirectBack(\n event: H3Event,\n opts: { fallback?: string; status?: number; allowQuery?: boolean } = {},\n): HTTPResponse {\n const referer = event.req.headers.get(\"referer\");\n let location = opts.fallback ?? \"/\";\n if (referer \u0026\u0026 URL.canParse(referer)) {\n const refererURL = new URL(referer);\n if (refererURL.origin === event.url.origin) {\n // BUG: pathname can be \"//evil.com/path\" which browsers interpret\n // as a protocol-relative URL\n location = refererURL.pathname + (opts.allowQuery ? refererURL.search : \"\");\n }\n }\n return redirect(location, opts.status);\n}\n```\n\nThe root cause is a discrepancy between how the WHATWG URL parser and browsers handle double-slash paths:\n\n1. `new URL(\"http://target.com//evil.com/path\").origin` \u2192 `\"http://target.com\"` \u2014 origin check **passes**\n2. `new URL(\"http://target.com//evil.com/path\").pathname` \u2192 `\"//evil.com/path\"` \u2014 extracted as redirect location\n3. Browser receives `Location: //evil.com/path` \u2192 interprets as protocol-relative URL \u2192 **redirects to `evil.com`**\n\n**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.\n\n## PoC\n\n```bash\n# 1. Create a minimal h3 app with redirectBack\ncat \u003e /tmp/h3-redirect-poc.ts \u003c\u003c \u0027SCRIPT\u0027\nimport { H3, redirectBack } from \"h3\";\n\nconst app = new H3();\napp.post(\"/submit\", (event) =\u003e redirectBack(event));\n\nconst res = await app.fetch(new Request(\"http://localhost/submit\", {\n method: \"POST\",\n headers: { referer: \"http://localhost//evil.com/steal\" }\n}));\n\nconsole.log(\"Status:\", res.status);\nconsole.log(\"Location:\", res.headers.get(\"location\"));\n// Expected: a same-origin path\n// Actual: \"//evil.com/steal\" \u2014 protocol-relative redirect to evil.com\nSCRIPT\n\n# 2. Verify URL parsing behavior\nnode -e \"\nconst u = new URL(\u0027http://localhost//evil.com/steal\u0027);\nconsole.log(\u0027origin:\u0027, u.origin); // http://localhost\nconsole.log(\u0027pathname:\u0027, u.pathname); // //evil.com/steal\nconsole.log(\u0027origin matches localhost:\u0027, u.origin === \u0027http://localhost\u0027); // true\n\"\n# Output:\n# origin: http://localhost\n# pathname: //evil.com/steal\n# origin matches localhost: true\n```\n\n## Impact\n\nAn attacker can redirect users from a trusted application to an attacker-controlled domain. This enables:\n\n- **Credential phishing**: Redirect to a lookalike login page to harvest credentials\n- **OAuth token theft**: In OAuth flows using `redirectBack()`, steal authorization codes by redirecting to an attacker\u0027s callback\n- **Trust exploitation**: Users see the initial link points to the trusted domain, lowering suspicion\n\nThe vulnerability requires no authentication and affects any endpoint using `redirectBack()`.\n\n## Recommended Fix\n\nSanitize the extracted pathname to prevent protocol-relative URLs. In `src/utils/response.ts`, after extracting the pathname from the referer:\n\n```typescript\nexport function redirectBack(\n event: H3Event,\n opts: { fallback?: string; status?: number; allowQuery?: boolean } = {},\n): HTTPResponse {\n const referer = event.req.headers.get(\"referer\");\n let location = opts.fallback ?? \"/\";\n if (referer \u0026\u0026 URL.canParse(referer)) {\n const refererURL = new URL(referer);\n if (refererURL.origin === event.url.origin) {\n let pathname = refererURL.pathname;\n // Prevent protocol-relative open redirect (e.g., \"//evil.com\")\n if (pathname.startsWith(\"//\")) {\n pathname = \"/\" + pathname.replace(/^\\/+/, \"\");\n }\n location = pathname + (opts.allowQuery ? refererURL.search : \"\");\n }\n }\n return redirect(location, opts.status);\n}\n```",
"id": "GHSA-fp4x-ggrf-wmc6",
"modified": "2026-03-23T21:48:24Z",
"published": "2026-03-23T21:48:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/h3js/h3/security/advisories/GHSA-fp4x-ggrf-wmc6"
},
{
"type": "WEB",
"url": "https://github.com/h3js/h3/commit/459a1c6593365b0810e9c502df7c3e82837321d7"
},
{
"type": "PACKAGE",
"url": "https://github.com/h3js/h3"
},
{
"type": "WEB",
"url": "https://github.com/h3js/h3/releases/tag/v2.0.1-rc.18"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "H3 has an Open Redirect via Protocol-Relative Path in redirectBack() Referer Validation"
}
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.