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.
This commit is contained in:
2026-08-30 23:57:00 +00:00
parent 9819c24b4f
commit d81e7ef319
+142 -6
View File
@@ -474,12 +474,27 @@ fn scalar_value_to_string(value: &Value) -> String {
/// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is /// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is
/// encoded so that a rendered value stays one literal path segment /// encoded so that a rendered value stays one literal path segment
/// (FWD-01 traversal/ smuggled-segments gate). Scalars are never /// (FWD-01 traversal/ smuggled-segments gate). Scalars are never
/// rejected: they are rendered safely instead. Object/array values /// rejected: they are rendered safely instead — with the lone-dot
/// under a placeholder key are rejected with `INVALID_INPUT` (FWD-18): /// exception below (FWD-13). Object/array values under a placeholder
/// a path placeholder designates exactly one literal segment, so a /// key are rejected with `INVALID_INPUT` (FWD-18): a path placeholder
/// structural value has no faithful rendering — the pre-decision /// designates exactly one literal segment, so a structural value has no
/// behavior spliced the minified JSON into the path *and* emitted the /// faithful rendering — the pre-decision behavior spliced the minified
/// key as a query parameter (double-routed, neither faithful). /// 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<String, CallError> { pub(crate) fn value_to_path_segment(value: &Value) -> Result<String, CallError> {
let raw = match value { let raw = match value {
Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => { Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => {
@@ -492,9 +507,29 @@ pub(crate) fn value_to_path_segment(value: &Value) -> Result<String, CallError>
))) )))
} }
}; };
reject_lone_dot_value(value, &raw)?;
Ok(utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string()) 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: `&`/`=` /// Raw form of a scalar input value for query emission: `&`/`=`
/// separation, escaping, and space encoding are handled by /// separation, escaping, and space encoding are handled by
/// `url::query_pairs_mut` downstream, so the value itself must be raw — /// `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"); 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 { fn ctx_with_capability(namespace: &str, value: String) -> OperationContext {
let mut ctx = noop_context(); let mut ctx = noop_context();
ctx.capabilities = Capabilities::new().with_http_token(namespace, value); ctx.capabilities = Capabilities::new().with_http_token(namespace, value);