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:
+142
-6
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user