{"repro_id":"REPRO-2026-00146","version":8,"title":"fast-uri: host confusion via percent-encoded authority delimiter in normalize()","repro_type":"security","status":"published","severity":"high","description":"`fast-uri`'s `normalize()` decodes percent-encoded authority delimiters that\nappear inside the host and then re-emits them **raw**. The `@` character is a\nuserinfo separator in RFC 3986 authority syntax, so re-emitting a decoded `@`\nchanges how the authority is parsed.\n\nAn attacker supplies a host string combining an allowed domain, an encoded `@`\n(`%40`), and a second domain — e.g. `allowed.com%40attacker.com`. The\nvulnerable build decodes the `%40` and emits a raw `@`, so the normalized\nauthority becomes `allowed.com@attacker.com`: `allowed.com` is reinterpreted as\nuserinfo and the **effective host is the second domain**, `attacker.com`.\n\nAny host-allowlist, SSRF guard, or redirect-validation check built on top of\n`normalize()` is therefore defeated — the validation logic sees the allowed\ndomain while the effective target is attacker-controlled.","root_cause":"# Root Cause Analysis: CVE-2026-6322\n\n## Summary\n\n`fast-uri`'s `normalize()` function decodes percent-encoded authority delimiter characters (specifically `@`) that appear inside the host component and then re-emits them as raw characters. When an attacker supplies a URI such as `https://allowed.com%40attacker.com/`, the vulnerable build decodes `%40` to `@`, producing `https://allowed.com@attacker.com/`. Under RFC 3986 authority syntax, the text before `@` is reinterpreted as userinfo and the effective host becomes `attacker.com`. This host confusion defeats any host-allowlist, SSRF guard, or redirect-validation logic built on top of `normalize()`.\n\n## Impact\n\n- **Package**: `fast-uri` (npm)\n- **Affected versions**: `<= 3.1.1`\n- **Fixed version**: `3.1.2`\n- **Risk level**: High (CVSS 7.5)\n- **Consequences**: SSRF bypass, open-redirect bypass, and general defeat of URI-based access controls that rely on normalized host values.\n\n## Root Cause\n\nIn `fast-uri` `<= 3.1.1`, the `parse()` function unconditionally calls `unescape(parsed.host)` during normalization. `unescape()` decodes all percent-encoded sequences, including RFC 3986 \"gen-delims\" such as `@`, `:`, `/`, `?`, and `#`. These delimiters are structurally significant in the authority component. After decoding, `serialize()` / `recomposeAuthority()` re-emits them as raw characters, changing the parsed structure of the URI.\n\nThe fix in `3.1.2` introduces `reescapeHostDelimiters()` in `lib/utils.js`. After `unescape()` runs on the host, this new function scans for decoded gen-delims and re-escapes them (`@` → `%40`, `:` → `%3A`, etc.). Additionally, `normalizeStringWithStatus()` detects malformed authorities or out-of-range ports and, when found, returns the original input unchanged rather than canonicalizing it into a different valid URI. The key fix commit is the `v3.1.1..v3.1.2` tag range in the `fastify/fast-uri` repository.\n\n## Reproduction Steps\n\n1. Run `repro/reproduction_steps.sh`.\n2. The script installs `fast-uri@3.1.1` (vulnerable) and `fast-uri@3.1.2` (fixed) into isolated scratch directories.\n3. It invokes `normalize('https://allowed.com%40attacker.com/')` against both versions and parses the resulting URI.\n4. Expected evidence:\n   - **Vulnerable (3.1.1)**: normalized URI is `https://allowed.com@attacker.com/`, parsed `host` = `attacker.com`, parsed `userinfo` = `allowed.com`.\n   - **Fixed (3.1.2)**: normalized URI is `https://allowed.com%40attacker.com/`, parsed `host` = `allowed.com%40attacker.com`, parsed `userinfo` = `undefined`.\n\n## Evidence\n\n- Log files:\n  - `logs/vuln_result.json` — captured result from the vulnerable build.\n  - `logs/fixed_result.json` — captured result from the fixed build.\n- Key excerpts from reproduction run:\n  ```\n  VULNERABLE BUILD (3.1.1)\n    Normalized URI:  https://allowed.com@attacker.com/\n    Parsed host:     attacker.com\n    Parsed userinfo: allowed.com\n\n  FIXED BUILD (3.1.2)\n    Normalized URI:  https://allowed.com%40attacker.com/\n    Parsed host:     allowed.com%40attacker.com\n    Parsed userinfo: undefined\n  ```\n- Environment: Node.js (npm-based) running on Linux with `fast-uri` installed from the public npm registry.\n\n## Recommendations / Next Steps\n\n- **Upgrade immediately** to `fast-uri@3.1.2` or later.\n- **If upgrading is not possible**: wrap `normalize()` output with an additional parse-and-validate step that rejects any host containing `@` or other authority delimiters after normalization.\n- **Testing recommendations**: add regression tests for percent-encoded delimiters inside the host (`%40`, `%3A`, `%2F`, `%3F`, `%23`) to ensure they remain escaped through `normalize()`, `parse()`, `serialize()`, and `equal()`.\n\n## Additional Notes\n\n- The reproduction script is idempotent: it passed two consecutive runs without modification.\n- Edge cases tested implicitly by the fix include double-encoded sequences (`%2540` → `%40`, not `@`) and IPv6 hosts where colon re-escaping is skipped.\n- The `equal()` function was also hardened in `3.1.2` to return `false` when either input is malformed, preventing accidental equivalence between `trusted.com%40evil.com` and `trusted.com@evil.com`.\n","ghsa_id":"GHSA-v39h-62p7-jpjc","cve_id":"CVE-2026-6322","cwe_id":"CWE-436 (Interpretation Conflict)","package":{"name":"fast-uri","ecosystem":"npm","affected_versions":"<= 3.1.1","fixed_version":"3.1.2","tested_patched":"3.1.2"},"reproduced_at":"2026-05-22T18:01:54.838762+00:00","duration_secs":618.1865592002869,"tool_calls":127,"turns":97,"handoffs":2,"total_cost_usd":1.0317724200000002,"agent_costs":{"repro":0.12829694,"support":0.02967149,"vuln_variant":0.87380399},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.12829694},"support":{"accounts/fireworks/models/kimi-k2p6":0.02967149},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":0.87380399}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:01:56.767714+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":4167,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3388,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7406,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":4529,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":2921,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":685,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3159,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1363,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4984,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2779,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2128,"category":"other"},{"path":"vuln_variant/scratch/package.json","filename":"package.json","size":53,"category":"other"},{"path":"logs/vuln_result.json","filename":"vuln_result.json","size":182,"category":"other"},{"path":"logs/fixed_result.json","filename":"fixed_result.json","size":189,"category":"other"},{"path":"logs/vuln_variant/fixed_variant_results.json","filename":"fixed_variant_results.json","size":2124,"category":"other"},{"path":"logs/vuln_variant/vuln_variant_results.json","filename":"vuln_variant_results.json","size":2012,"category":"other"},{"path":"logs/vuln_variant/run_variants.js","filename":"run_variants.js","size":1931,"category":"other"}]}