--- id: review-001-forward-url-safety name: Safe outbound URL construction — encoding, base-path, host validation (FWD-01, FWD-02) status: pending depends_on: [] scope: narrow risk: high impact: component level: implementation tags: [adapters, review-001, security] --- ## Description Review 001 findings FWD-01 + FWD-02, both empirically verified against `src/adapters/forward.rs` and both security-bearing **with the operation's injected credentials attached**: - **FWD-01**: path-parameter values are substituted raw (`forward.rs:56-71, 133-141`). `Url::join` normalizes `..`, so `{owner} = "../../admin"` escapes a path-scoped prefix (cross-tenant IDOR); `?` and `#` in a value split/inject into the URL; values containing a later placeholder get re-expanded by the iterative substitution. Fix: percent-encode each segment (`utf8_percent_encode` with a path-segment set), reject/encode `?`/`#`, and render the template in a single pass (not iterative replace). The query path is already correctly encoded via `query_pairs_mut` — keep that shape. - **FWD-02**: `Url::join` resolves against the base *directory*, so `base_url = "https://api.openai.com/v1"` + `/chat/completions` silently drops `/v1` (`forward.rs:74-78`); every test uses origin-only base URLs so the suite can't see it. Worse: a path key that is an absolute URL replaces scheme+host entirely (verified — reqwest only rejects non-http(s) *schemes*), and credential injection happens after URL construction, so a spec-controlled absolute path sends the namespace's credentials to an arbitrary host (SSRF). Specs are assembly-layer-supplied (trusted per ADR-066) but nothing enforces that boundary. Fix: append to the base *path* (not origin), require the joined URL to keep the base host (fail loudly on host change), and require https by default (explicit opt-out for http). ## Acceptance Criteria - [ ] Traversal test: `{owner} = "../../admin"` cannot escape the template path (segment encoded or rejected) - [ ] `?`/`#`/later-placeholder-in-value tested (encoded or rejected, never URL-structural) - [ ] Base URL with a path prefix keeps the prefix (`…/v1` + `/chat/completions` → `…/v1/chat/completions`, test) - [ ] Absolute-URL path template is rejected loudly; joined host ≠ base host is rejected; http base refused unless opted out (tests) - [ ] Rendering is single-pass (a rendered value is never re-substituted) - [ ] Both `from_openapi` and `from_jsonschema` paths covered (they share `forward.rs`) - [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass ## References - docs/reviews/001-initial-implementation-review.md (Part D, FWD-01, FWD-02) - docs/architecture/decisions/066-from-jsonschema-as-http-adapter.md ## Notes > Agent fills during implementation. One of the two gate tasks for > any deployment-facing milestone (with review-001-client-timeout-retry). ## Summary > Filled on completion.