{"repro_id":"REPRO-2026-00145","version":8,"title":"fast-uri: path traversal via percent-encoded segments decoded before normalization","repro_type":"security","status":"published","severity":"high","description":"`fast-uri`'s `normalize()` and `equal()` decode percent-encoded path\nseparators and dot segments **before** applying RFC 3986 dot-segment removal.\nBecause of this ordering, an encoded `%2e%2e%2f` sequence is first decoded into\na literal `../` and is then processed as a real dot segment during\nnormalization.\n\nThe consequence is that two distinct input URIs collapse onto the same\nnormalized path. Any path-prefix allowlist check built on top of\n`normalize()` / `equal()` can therefore be bypassed: an attacker supplies an\nencoded traversal sequence, the normalized result escapes the intended path\nprefix, and the allowlist comparison no longer reflects the real target.\n\nAny code that forwards an attacker-influenced URI string into `normalize()` or\n`equal()` and then makes a path-prefix authorization decision on the result is\nexploitable.","root_cause":"# RCA Report: CVE-2026-6321 — fast-uri Path Traversal via Pre-Decoded Percent-Encoded Segments\n\n## Summary\n\n`fast-uri` versions 3.1.0 and earlier decode percent-encoded path separators (`%2F`) and dot segments (`%2E`) before applying RFC 3986 dot-segment removal during URI normalization. This allows an attacker to bypass path-prefix allowlist checks: an encoded traversal sequence such as `%2e%2e%2f` is first decoded into literal `../`, then processed as a real dot segment, causing the normalized path to escape the intended prefix. The `equal()` function suffers the same flaw, incorrectly reporting two distinct URIs as equal when one uses encoded traversal.\n\n## Impact\n\n- **Package**: `fast-uri` (npm)\n- **Affected versions**: `<= 3.1.0`\n- **Fixed version**: `3.1.1`\n- **CVE**: CVE-2026-6321\n- **CWE**: CWE-22 (Path Traversal)\n- **Severity**: High (CVSS 7.5)\n- **Consequences**: Any authorization logic that relies on `fast-uri.normalize()` or `fast-uri.equal()` to validate a path prefix can be bypassed. An attacker-supplied URI may normalize to a path outside the intended directory, leading to unauthorized access.\n\n## Root Cause\n\nThe vulnerability stems from the order of operations in the `parse()` and `serialize()` pipeline:\n\n1. **Vulnerable (v3.1.0)**: `parse()` calls `parsed.path = escape(unescape(parsed.path))`. The built-in `unescape()` converts `%2e` to `.` and `%2f` to `/`, so `%2e%2e%2f` becomes the literal string `../`. When `serialize()` later calls `removeDotSegments()` on this already-decoded path, the sequence is treated as a real parent-directory traversal instruction, collapsing `public/../admin` to `admin`.\n\n2. **Fixed (v3.1.1)**: The code replaces the blanket `escape(unescape(...))` with `normalizePathEncoding()`, which deliberately preserves reserved path escapes such as `%2F` and `%2E`. Because these remain encoded, `removeDotSegments()` sees them as ordinary path data, not as dot segments, and the path stays confined.\n\nThe fix commit changes `index.js` and `lib/utils.js` and adds a regression test (`test/security-normalization.test.js`) that asserts `%2E%2E` stays encoded during normalization and that `equal()` no longer conflates encoded and literal separators.\n\n## Reproduction Steps\n\nThe reproduction is fully automated by `repro/reproduction_steps.sh`.\n\nWhat the script does:\n1. Creates two isolated npm projects.\n2. Installs `fast-uri@3.1.0` (vulnerable) in one project.\n3. Runs a Node.js script that calls `normalize()` and `equal()` on URIs containing encoded traversal sequences (`%2e%2e`, `%2f`).\n4. Installs `fast-uri@3.1.1` (fixed) in the second project.\n5. Runs the identical test script.\n6. Compares the outputs and writes a JSON verdict.\n\nExpected evidence:\n- **Vulnerable**: `normalize('http://example.com/public/%2e%2e/admin')` returns `http://example.com/admin`.\n- **Fixed**: `normalize('http://example.com/public/%2e%2e/admin')` returns `http://example.com/public/%2E%2E/admin`.\n- **Vulnerable**: `equal('http://example.com/public/%2e%2e/admin', 'http://example.com/admin')` returns `true`.\n- **Fixed**: `equal(...)` returns `false`.\n\n## Evidence\n\n- **Vulnerable output log**: `logs/vuln-output.txt`\n- **Fixed output log**: `logs/fixed-output.txt`\n- **Validation verdict**: `logs/validation_verdict.json`\n\nKey excerpts from the vulnerable run:\n```\nINPUT:  http://example.com/public/%2e%2e/admin\nOUTPUT: http://example.com/admin\n\nequal('http://example.com/public/%2e%2e/admin', 'http://example.com/admin') => true\n```\n\nKey excerpts from the fixed run:\n```\nINPUT:  http://example.com/public/%2e%2e/admin\nOUTPUT: http://example.com/public/%2E%2E/admin\n\nequal('http://example.com/public/%2e%2e/admin', 'http://example.com/admin') => false\n```\n\nEnvironment:\n- Node.js runtime (any recent LTS)\n- No external services, databases, or browsers required\n- Pure in-process JavaScript test\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `fast-uri@3.1.1` or later.\n2. **Audit existing code** that uses `normalize()` or `equal()` for path-prefix or URI-allowlist decisions. Replace any ad-hoc workarounds with the patched library.\n3. **Add regression tests** for encoded traversal sequences (`%2e%2e%2f`, `%2e%2e`, `%2f`) in your own URI-handling logic.\n4. **Consider defense in depth**: wherever possible, validate resolved filesystem paths (e.g., using `path.resolve()` and checking the result is under a trusted root) rather than relying solely on URI normalization.\n\n## Additional Notes\n\n- **Idempotency**: The reproduction script was executed twice consecutively with identical results, confirming idempotency.\n- **Edge cases tested**: single encoded slash (`%2f`), double encoded dot-dot (`%2e%2e`), chained sequences (`%2e%2e/%2e%2e`), and the `equal()` false-positive case. All behaved consistently with the vulnerability description.\n","ghsa_id":"GHSA-q3j6-qgpj-74h6","cve_id":"CVE-2026-6321","cwe_id":"CWE-22 (Path Traversal)","source_url":"https://github.com/fastify/fast-uri.git","package":{"name":"fast-uri","ecosystem":"npm","affected_versions":"<= 3.1.0","fixed_version":"3.1.1","tested_patched":"3.1.1"},"reproduced_at":"2026-05-22T18:01:34.613389+00:00","duration_secs":608.0578036308289,"tool_calls":154,"turns":132,"handoffs":3,"total_cost_usd":1.1968997800000003,"agent_costs":{"repro":0.2441995,"support":0.03299524,"vuln_variant":0.91970504},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.2441995},"support":{"accounts/fireworks/models/kimi-k2p6":0.03299524},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":0.91970504}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:01:36.788679+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":4818,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6250,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":6053,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":5963,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":2892,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":667,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3389,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":580,"category":"other"},{"path":"repro/fixed/variant_tests.js","filename":"variant_tests.js","size":2356,"category":"other"},{"path":"repro/fixed/package.json","filename":"package.json","size":76,"category":"other"},{"path":"repro/fixed/test-vuln.js","filename":"test-vuln.js","size":776,"category":"other"},{"path":"repro/vuln/variant_tests.js","filename":"variant_tests.js","size":2356,"category":"other"},{"path":"repro/vuln/package.json","filename":"package.json","size":75,"category":"other"},{"path":"repro/vuln/test-vuln.js","filename":"test-vuln.js","size":776,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1352,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":5052,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2532,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1857,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":769,"category":"other"},{"path":"vuln_variant/test_env/latest312/variant_test.js","filename":"variant_test.js","size":3081,"category":"other"},{"path":"vuln_variant/test_env/latest312/package.json","filename":"package.json","size":273,"category":"other"},{"path":"vuln_variant/test_env/fixed311/variant_test.js","filename":"variant_test.js","size":3081,"category":"other"},{"path":"vuln_variant/test_env/fixed311/package.json","filename":"package.json","size":272,"category":"other"},{"path":"vuln_variant/test_env/vuln310/variant_test.js","filename":"variant_test.js","size":3081,"category":"other"},{"path":"vuln_variant/test_env/vuln310/package.json","filename":"package.json","size":271,"category":"other"},{"path":"logs/fixed-output.txt","filename":"fixed-output.txt","size":561,"category":"other"},{"path":"logs/fixed_311_test.log","filename":"fixed_311_test.log","size":1202,"category":"log"},{"path":"logs/vuln_310_test.log","filename":"vuln_310_test.log","size":1186,"category":"log"},{"path":"logs/variant_test.js","filename":"variant_test.js","size":3081,"category":"other"},{"path":"logs/variant_tests.js","filename":"variant_tests.js","size":2356,"category":"other"},{"path":"logs/vuln-output.txt","filename":"vuln-output.txt","size":512,"category":"other"},{"path":"logs/fixed_variant_tests.txt","filename":"fixed_variant_tests.txt","size":2114,"category":"other"},{"path":"logs/latest_312_test.log","filename":"latest_312_test.log","size":1181,"category":"log"},{"path":"logs/validation_verdict.json","filename":"validation_verdict.json","size":580,"category":"other"},{"path":"logs/test-vuln.mjs","filename":"test-vuln.mjs","size":771,"category":"other"},{"path":"logs/vuln_variant_tests.txt","filename":"vuln_variant_tests.txt","size":1926,"category":"other"},{"path":"logs/test-vuln.js","filename":"test-vuln.js","size":776,"category":"other"}]}