From d81e7ef3197a568ed0fbc85c2e1f7d9d1d7bb213 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 23:57:00 +0000 Subject: [PATCH] fix(adapters): reject lone dot/dot-dot path values normalized away by Url::set_path (FWD-13) - value_to_path_segment now rejects scalar values whose decoded form is exactly '.', '..', '%2e', '%2E', '%2e%2e', or '%2E%2E' (case-insensitive) with INVALID_INPUT: url 2.5.8 Url::set_path silently normalizes lone dot segments away, so such values would route to a different upstream endpoint than the template describes, with namespace credentials attached - rejection is exact-match on the full decoded segment: dotted values like v1.2.3, .hidden-file, ..hidden, ... still render - error names the failure mode but never echoes the raw value - empirically pins the set_path normalization behavior in a test (tenants/../resources -> /resources, /files/.. -> /, %2E%2E -> normalized) Verification: scripts/verify.sh (382 passed) and --all-features (498 passed), clippy -D warnings, fmt --check all pass. --- src/adapters/forward.rs | 148 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 6 deletions(-) diff --git a/src/adapters/forward.rs b/src/adapters/forward.rs index 16161a3..cb2da5e 100644 --- a/src/adapters/forward.rs +++ b/src/adapters/forward.rs @@ -474,12 +474,27 @@ fn scalar_value_to_string(value: &Value) -> String { /// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is /// encoded so that a rendered value stays one literal path segment /// (FWD-01 traversal/ smuggled-segments gate). Scalars are never -/// rejected: they are rendered safely instead. Object/array values -/// under a placeholder key are rejected with `INVALID_INPUT` (FWD-18): -/// a path placeholder designates exactly one literal segment, so a -/// structural value has no faithful rendering — the pre-decision -/// behavior spliced the minified JSON into the path *and* emitted the -/// key as a query parameter (double-routed, neither faithful). +/// rejected: they are rendered safely instead — with the lone-dot +/// exception below (FWD-13). Object/array values under a placeholder +/// key are rejected with `INVALID_INPUT` (FWD-18): a path placeholder +/// designates exactly one literal segment, so a structural value has no +/// faithful rendering — the pre-decision behavior spliced the minified +/// JSON into the path *and* emitted the key as a query parameter +/// (double-routed, neither faithful). +/// +/// # Lone-dot values (FWD-13) +/// +/// A scalar whose *decoded* form is exactly `.` or `..` — including the +/// `%2e`/`%2E` spellings, which `Url::set_path` normalizes away after +/// decoding regardless of what the encode set preserves — is rejected +/// with `INVALID_INPUT`. `Url::set_path` removes lone dot segments +/// case-insensitively (`/../x` → `/x`, `/./x` → `/x`, `/%2e%2e/x` → +/// `/x`), so no encode-set fix can keep such a value faithful: the +/// upstream would receive a *different* endpoint than the template +/// describes, with the namespace's credentials attached. The rejection +/// is exact-match on the full decoded segment; values like `v1.2.3` or +/// `.hidden-file` render normally. The parameter name is quoted but the +/// value is never echoed. pub(crate) fn value_to_path_segment(value: &Value) -> Result { let raw = match value { Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => { @@ -492,9 +507,29 @@ pub(crate) fn value_to_path_segment(value: &Value) -> Result ))) } }; + reject_lone_dot_value(value, &raw)?; Ok(utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string()) } +/// FWD-13 gate: a decoded path-value of exactly `.` or `..` cannot be +/// rendered faithfully — `Url::set_path` silently normalizes lone dot +/// segments away, so the upstream would receive a different endpoint +/// than the template describes. The parameter is named but the value is +/// not echoed. Called with both the original [`Value`] (for type-naming) +/// and its raw scalar string. +fn reject_lone_dot_value(value: &Value, raw: &str) -> Result<(), CallError> { + let type_label = type_name_of(value); + let is_lone_dot = matches!(raw, "." | "..") + || raw.eq_ignore_ascii_case("%2e") + || raw.eq_ignore_ascii_case("%2e%2e"); + if is_lone_dot { + return Err(CallError::invalid_input(format!( + "path placeholder value of type {type_label} decodes to a lone dot segment, which cannot appear in a rendered path (Url::set_path would silently normalize it away); pass a concrete non-dot value" + ))); + } + Ok(()) +} + /// Raw form of a scalar input value for query emission: `&`/`=` /// separation, escaping, and space encoding are handled by /// `url::query_pairs_mut` downstream, so the value itself must be raw — @@ -1687,6 +1722,107 @@ mod tests { assert_eq!(url.path(), "/files/a%252Fb"); } + /// Empirical pin of the failure mode FWD-13 describes, against the + /// locked `url` crate: lone `.`/`..` (and their percent-escaped + /// spellings) are silently *normalized away* by `Url::set_path`, so + /// the encode set alone cannot keep the rendered path faithful. + #[test] + fn url_set_path_normalizes_lone_dot_and_dot_dot_path_values() { + let mut url = Url::parse("https://api.example.com").expect("parses"); + for (spliced, normalized) in [ + ("/tenants/../resources", "/resources"), + ("/tenants/./resources", "/tenants/resources"), + ("/files/..", "/"), + ("/repos/%2E%2E/x", "/x"), + ] { + url.set_path(spliced); + assert_eq!( + url.path(), + normalized, + "set_path silently normalized `{spliced}`" + ); + } + } + + #[test] + fn lone_dot_dot_path_value_is_rejected() { + let err = request_url( + "https://api.example.com", + "/tenants/{tenant}/resources", + json!({"tenant": ".."}), + ) + .expect_err("lone `..` value must be rejected"); + assert_eq!(err.code, "INVALID_INPUT"); + assert!( + err.message.contains("lone dot segment"), + "message must explain the lone-dot rejection: {}", + err.message + ); + assert!( + !err.message.contains(".."), + "error must not echo the raw value: {}", + err.message + ); + } + + #[test] + fn lone_dot_path_value_is_rejected() { + let err = request_url( + "https://api.example.com", + "/tenants/{tenant}/resources", + json!({"tenant": "."}), + ) + .expect_err("lone `.` value must be rejected"); + assert_eq!(err.code, "INVALID_INPUT"); + } + + #[test] + fn percent_escapes_spellings_of_lone_dots_are_rejected() { + for value in ["\u{2e}\u{2e}", "%2e%2e", "%2E%2e", "%2e%2E"] { + let err = request_url( + "https://api.example.com", + "/tenants/{tenant}/resources", + json!({ "tenant": value }), + ) + .expect_err("escaped lone-dot spellings must be rejected"); + assert_eq!(err.code, "INVALID_INPUT", "value was: {value}"); + } + + for value in ["%2e", "%2E"] { + let err = request_url( + "https://api.example.com", + "/tenants/{tenant}/resources", + json!({ "tenant": value }), + ) + .expect_err("escaped lone-dot spellings must be rejected"); + assert_eq!(err.code, "INVALID_INPUT", "value was: {value}"); + } + } + + #[test] + fn dotted_but_not_lone_dot_values_still_render() { + for value in [ + "v1.2.3", + ".hidden-file", + "..hidden", + "hidden..", + "a..b", + ".a.b.", + "...", + ] { + let url = request_url( + "https://api.example.com", + "/files/{name}", + json!({ "name": value }), + ) + .unwrap_or_else(|e| panic!("value `{value}` must render: {e:?}")); + assert!( + url.path().starts_with("/files/"), + "value `{value}` rendered into the path" + ); + } + } + fn ctx_with_capability(namespace: &str, value: String) -> OperationContext { let mut ctx = noop_context(); ctx.capabilities = Capabilities::new().with_http_token(namespace, value);