+ "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 && 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` → `\"http://target.com\"` — origin check **passes**\n2. `new URL(\"http://target.com//evil.com/path\").pathname` → `\"//evil.com/path\"` — extracted as redirect location\n3. Browser receives `Location: //evil.com/path` → interprets as protocol-relative URL → **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 > /tmp/h3-redirect-poc.ts << 'SCRIPT'\nimport { H3, redirectBack } from \"h3\";\n\nconst app = new H3();\napp.post(\"/submit\", (event) => 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\" — protocol-relative redirect to evil.com\nSCRIPT\n\n# 2. Verify URL parsing behavior\nnode -e \"\nconst u = new URL('http://localhost//evil.com/steal');\nconsole.log('origin:', u.origin); // http://localhost\nconsole.log('pathname:', u.pathname); // //evil.com/steal\nconsole.log('origin matches localhost:', u.origin === 'http://localhost'); // 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's 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 && 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```",
0 commit comments