Skip to content

Commit 1a91d93

Browse files
committed
Reject ambiguous encoded forward paths
1 parent 148d86a commit 1a91d93

3 files changed

Lines changed: 44 additions & 6 deletions

File tree

crates/fluxheim-common/src/path_safety.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@ fn safe_forward_path_segment(segment: &str) -> bool {
3232
if unsafe_decoded_forward_path_segment(&decoded) {
3333
return false;
3434
}
35-
match std::str::from_utf8(&decoded) {
36-
Ok(decoded_text) if decoded_text.contains('%') => {
37-
current.clear();
38-
current.push_str(decoded_text);
39-
}
40-
_ => return true,
35+
let Ok(decoded_text) = std::str::from_utf8(&decoded) else {
36+
return false;
37+
};
38+
if decoded_text.chars().any(char::is_control) {
39+
return false;
40+
}
41+
if !decoded_text.contains('%') {
42+
return true;
4143
}
44+
current.clear();
45+
current.push_str(decoded_text);
4246
}
4347

4448
if current.contains('%') {
@@ -115,5 +119,14 @@ mod tests {
115119
#[test]
116120
fn accepts_safe_percent_encoded_path_and_query() {
117121
assert!(safe_forward_path_and_query("/assets/%66ile.css?v=%252e"));
122+
assert!(safe_forward_path("/caf%C3%A9/menu"));
123+
}
124+
125+
#[test]
126+
fn rejects_invalid_utf8_and_encoded_unicode_controls() {
127+
assert!(!safe_forward_path("/%c0%afadmin"));
128+
assert!(!safe_forward_path("/%ff"));
129+
assert!(!safe_forward_path("/%c2%85admin"));
130+
assert!(!safe_forward_path("/%25c2%2585admin"));
118131
}
119132
}

crates/fluxheim-server/src/native_http1_route_redirect_tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ async fn native_route_proxy_redirect_rejects_unsafe_uri_expansion() {
3939
assert!(response.ends_with("invalid redirect target\n"));
4040
}
4141

42+
#[tokio::test]
43+
async fn native_route_proxy_redirect_rejects_ambiguous_encoded_path() {
44+
let route = NativeHttp1RouteProxyRoute::prefix_redirect(
45+
"/old",
46+
Vec::new(),
47+
"https://new.example{uri}",
48+
308,
49+
);
50+
let proxy = route_proxy_listener(NativeHttp1RouteProxy::new(vec![route], None)).await;
51+
52+
for target in ["/old/%c0%afadmin", "/old/%ff", "/old/%c2%85admin"] {
53+
let response = downstream_get(proxy, target).await;
54+
assert!(
55+
response.starts_with("HTTP/1.1 400 Bad Request\r\n"),
56+
"target: {target}"
57+
);
58+
assert!(response.ends_with("invalid redirect target\n"));
59+
}
60+
}
61+
4262
#[tokio::test]
4363
async fn native_route_proxy_redirect_rejects_query_path_traversal_expansion() {
4464
let route = NativeHttp1RouteProxyRoute::exact_redirect(

release-notes/RELEASE_NOTES_1.7.12.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ trailers are not part of this release.
8181
range admission, reject impossible totals and duplicate metadata, and make
8282
zero-sized public slice planning return no slices instead of dividing by
8383
zero.
84+
- Reject percent-decoded forward-path segments that are not canonical UTF-8 or
85+
contain encoded Unicode control characters, preventing disagreement with
86+
permissive upstream decoders.
8487
- Bound storage-bin manifests to 4 KiB and use no-follow, nonblocking regular
8588
file reads so oversized or special persistent files fail closed at startup.
8689

@@ -120,3 +123,5 @@ platform, configuration, key handling, and required compliance evidence.
120123
oversized/FIFO storage-bin manifests.
121124
- Cache-header tests cover cumulative byte/directive ceilings and quoted-comma
122125
parsing.
126+
- Shared path-safety and live redirect tests cover invalid UTF-8, overlong
127+
slash encodings, encoded Unicode controls, and valid encoded Unicode.

0 commit comments

Comments
 (0)