{"repro_id":"REPRO-2026-00147","version":8,"title":"Faraday: SSRF via protocol-relative URL overriding base authority","repro_type":"security","status":"published","severity":"medium","description":"`faraday`'s `build_exclusive_url` uses Ruby's `URI#merge` to combine the\nconnection's base URL with a per-request path. Per RFC 3986, a\nprotocol-relative URL such as `//evil.com/path` is a *network-path reference*:\nwhen merged, its authority **overrides** the base URL's authority.\n\nAs a result, a user-controlled path passed to `get()` / `post()` can redirect\nthe outgoing request to an attacker-chosen host. An application that builds a\nFaraday connection against a trusted base host but forwards an\nattacker-influenced path string is exposed to server-side request forgery —\nthe request leaves the configured host entirely.","root_cause":"# RCA Report: CVE-2026-25765 — Faraday SSRF via Protocol-Relative URL\n\n## Summary\n\nThe `faraday` Ruby HTTP client library contained a Server-Side Request Forgery (SSRF) vulnerability in its `build_exclusive_url` method. When a connection was configured with a trusted base URL (e.g., `http://safe.local/api`) and a request path starting with `//` (a protocol-relative or \"network-path\" reference) was supplied, the `//` prefix incorrectly passed the existing relative-URL guard. Ruby's `URI#+` then interpreted the protocol-relative path as an authority override, replacing the base host with the attacker-specified host. This allowed an attacker-controlled path string to redirect outbound requests to arbitrary hosts, bypassing the intended scope of the Faraday connection.\n\n## Impact\n\n- **Package**: `faraday` (RubyGems)\n- **Affected versions**: `1.0.0`–`1.10.4` and `2.0.0`–`2.14.0`\n- **Fixed versions**: `1.10.5` and `2.14.1`\n- **CWE**: CWE-918 (Server-Side Request Forgery)\n- **Risk**: Medium — CVSS 5.8\n- **Consequences**: Any application that forwards user-influenced path strings to a Faraday connection without additional validation can be tricked into making requests to internal or attacker-controlled hosts, potentially exposing internal services, cloud metadata endpoints, or allowing request-smuggling attacks.\n\n## Root Cause\n\nThe vulnerable code lives in `lib/faraday/connection.rb` inside the `build_exclusive_url` method.\n\n**Vulnerable guard (v2.14.0)**:\n```ruby\nurl = \"./#{url}\" if url.respond_to?(:start_with?) && !url.start_with?('http://', 'https://', '/', './', '../')\n```\n\nBecause a protocol-relative URL such as `//evil.com/path` begins with `/`, the guard condition `!url.start_with?('/')` evaluates to `false`, so the URL is **not** prefixed with `./`. When `URI#+` merges the base URL (`http://safe.local`) with `//evil.com/path`, RFC 3986 dictates that the authority component of the merge target overrides the base authority. The resulting URI becomes `http://evil.com/path`, completely leaving the configured base host.\n\n**Fix commit**: [`a6d3a3a0bf59c2ab307d0abd91bc126aef5561bc`](https://github.com/lostisland/faraday/commit/a6d3a3a0bf59c2ab307d0abd91bc126aef5561bc)\n\n**Fixed guard (v2.14.1)**:\n```ruby\nurl = \"./#{url}\" if url.respond_to?(:start_with?) &&\n                    (!url.start_with?('http://', 'https://', '/', './', '../') || url.start_with?('//'))\n```\n\nThe fix explicitly detects the `//` prefix and prepends `./`, neutralising the authority component. The merged URI then stays scoped to the base host (`http://safe.local///evil.com/path`), preventing host override.\n\n## Reproduction Steps\n\n1. Run `repro/reproduction_steps.sh`.\n2. The script installs the vulnerable (`2.14.0`) and fixed (`2.14.1`) gem versions.\n3. It starts a local TCP listener on an ephemeral port to stand in for the attacker host.\n4. For each version it:\n   - Creates a Faraday connection with base URL `http://192.0.2.1` (TEST-NET-1, guaranteed non-routable).\n   - Calls `build_exclusive_url('//127.0.0.1:<port>/x')` and records the resulting host.\n   - Issues an actual HTTP `GET` with the same protocol-relative path.\n   - Counts how many requests the local listener received.\n5. Expected evidence:\n   - **Vulnerable (2.14.0)**: `built_host` is `127.0.0.1`, the request succeeds with status 404, and the local listener receives ≥1 request.\n   - **Fixed (2.14.1)**: `built_host` remains `192.0.2.1`, the request fails with `Connection refused`, and the local listener receives 0 requests.\n\n## Evidence\n\n- `logs/vulnerable.json` — Faraday 2.14.0 runtime results:\n  ```json\n  {\n    \"built_url\": \"http://127.0.0.1:33953/x\",\n    \"built_host\": \"127.0.0.1\",\n    \"request_status\": 404,\n    \"request_success\": true\n  }\n  ```\n- `logs/fixed.json` — Faraday 2.14.1 runtime results:\n  ```json\n  {\n    \"built_url\": \"http://192.0.2.1///127.0.0.1:33953/x\",\n    \"built_host\": \"192.0.2.1\",\n    \"request_error\": \"Faraday::ConnectionFailed: Failed to open TCP connection to 192.0.2.1:80 (Connection refused - connect(2) for \\\"192.0.2.1\\\" port 80)\",\n    \"request_success\": false\n  }\n  ```\n- `repro/runtime_manifest.json` — consolidated verdict:\n  ```json\n  {\n    \"verdict\": \"confirmed\",\n    \"vulnerable_version\": \"2.14.0\",\n    \"fixed_version\": \"2.14.1\",\n    \"vulnerable_built_host\": \"127.0.0.1\",\n    \"fixed_built_host\": \"192.0.2.1\",\n    \"vulnerable_request_succeeded\": true,\n    \"fixed_request_succeeded\": false,\n    \"vulnerable_listener_requests\": 2,\n    \"fixed_listener_requests\": 0\n  }\n  ```\n\n## Recommendations / Next Steps\n\n- **Upgrade**: Update `faraday` to `>= 1.10.5` or `>= 2.14.1` immediately.\n- **Input validation**: Applications accepting user-influenced URL paths should validate that paths do not begin with `//` before passing them to Faraday, as a defense-in-depth measure.\n- **Regression testing**: Add unit tests that assert `build_exclusive_url` rejects host override for `//evil.com`, `//evil.com:8080`, `//user:pass@evil.com`, and `///evil.com` (all covered by the upstream spec added in the fix commit).\n- **Network segmentation**: Where SSRF risk is high, restrict outbound connectivity from application servers to only required destinations.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` was executed twice consecutively and produced identical verdicts both times.\n- **Edge cases**: The reproduction script uses `192.0.2.1` (RFC 5737 TEST-NET-1) as the base host to guarantee the fixed version fails predictably with `Connection refused` rather than relying on DNS non-resolution, which can be unreliable in environments with wildcard DNS or ISP hijacking.\n- **Server log**: The local HTTP listener received **2 requests** during the vulnerable test and **0 requests** during the fixed test, providing direct runtime proof that the protocol-relative path was neutralised by the patch.\n","ghsa_id":"GHSA-33mh-2634-fwr2","cve_id":"CVE-2026-25765","cwe_id":"CWE-918 (Server-Side Request Forgery)","source_url":"https://github.com/lostisland/faraday","package":{"name":"faraday","ecosystem":"rubygems","affected_versions":"faraday >= 1.0.0, <= 1.10.4 and >= 2.0.0, <= 2.14.0"},"reproduced_at":"2026-05-22T18:01:58.333545+00:00","duration_secs":615.042418718338,"tool_calls":128,"turns":102,"handoffs":3,"total_cost_usd":0.6694526199999998,"agent_costs":{"repro":0.29065415999999994,"support":0.02256443,"vuln_variant":0.35623403},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.29065415999999994},"support":{"accounts/fireworks/models/kimi-k2p6":0.02256443},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":0.35623403}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:02:00.094956+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5851,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":5412,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":5471,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3968,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3062,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":721,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3469,"category":"ticket"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":312,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1001,"category":"other"},{"path":"vuln_variant/test_edge_cases.rb","filename":"test_edge_cases.rb","size":1038,"category":"other"},{"path":"vuln_variant/test_latest.rb","filename":"test_latest.rb","size":779,"category":"other"},{"path":"vuln_variant/test_vuln_2140.rb","filename":"test_vuln_2140.rb","size":646,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1018,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4547,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2517,"category":"other"},{"path":"vuln_variant/test_e2e_bypass.rb","filename":"test_e2e_bypass.rb","size":982,"category":"other"},{"path":"vuln_variant/test_bypass_2141.rb","filename":"test_bypass_2141.rb","size":703,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1224,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":814,"category":"other"},{"path":"vuln_variant/test_bypass.rb","filename":"test_bypass.rb","size":1044,"category":"other"},{"path":"logs/fixed.json","filename":"fixed.json","size":347,"category":"other"},{"path":"logs/vulnerable.json","filename":"vulnerable.json","size":204,"category":"other"},{"path":"logs/server_requests.log","filename":"server_requests.log","size":0,"category":"log"},{"path":"logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":189,"category":"other"},{"path":"logs/vuln_variant/variant_evidence.log","filename":"variant_evidence.log","size":2074,"category":"log"},{"path":"logs/variant_evidence.log","filename":"variant_evidence.log","size":2074,"category":"log"}]}