Skip to content

Commit 8abd171

Browse files
committed
Harden native route proxy cutover checks
1 parent e753215 commit 8abd171

10 files changed

Lines changed: 149 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ behavior when the change improves security or project direction.
3333
non-blocking candidate-detail rows in the evidence report.
3434
- Fail closed in the native route-proxy primitive for invalid request targets
3535
and unsafe rewritten paths.
36+
- Reject interior double-slash forward paths in the native route-proxy path so
37+
stripped or rewritten targets cannot be misclassified as upstream failures or
38+
forwarded with ambiguous path semantics.
39+
- Mark regex routes as native HTTP/1 compatibility blockers until the native
40+
route proxy supports regex matchers, and validate candidate-row shape in the
41+
native runtime cutover gate before ignoring candidate-detail rows.
42+
- Reject single-dot route path segments at config validation time so invalid
43+
strip/rewrite prefixes fail at startup instead of at request time.
3644
- Keep Pingora dependency exceptions enforced by target version so the final
3745
deletion cannot drift past the documented release without CI failing.
3846

crates/fluxheim-common/src/path_safety.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ pub fn safe_forward_path(path: &str) -> bool {
1212
{
1313
return false;
1414
}
15+
if path.len() > 1 && path.contains("//") {
16+
return false;
17+
}
1518

1619
path.split('/').all(safe_forward_path_segment)
1720
}
@@ -97,6 +100,13 @@ mod tests {
97100
assert!(safe_forward_path("/.well-known/acme-challenge/token"));
98101
}
99102

103+
#[test]
104+
fn rejects_empty_interior_segments() {
105+
assert!(!safe_forward_path("//admin"));
106+
assert!(!safe_forward_path("/public//admin"));
107+
assert!(safe_forward_path("/"));
108+
}
109+
100110
#[test]
101111
fn rejects_remaining_percent_after_decode_budget() {
102112
assert!(!safe_forward_path("/%2525252e%2525252e/secret"));

crates/fluxheim-config/src/config_route.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,9 @@ pub fn validate_route_path(
469469
|| value.contains('?')
470470
|| value.contains('#')
471471
|| value.chars().any(char::is_control)
472-
|| value.split('/').any(|segment| segment == "..")
472+
|| value
473+
.split('/')
474+
.any(|segment| matches!(segment, "." | ".."))
473475
{
474476
return Err(ConfigError::InvalidRouteMatcher {
475477
vhost: String::new(),

crates/fluxheim-config/src/config_tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12307,6 +12307,32 @@ fn rejects_invalid_vhost_routes() {
1230712307
})
1230812308
);
1230912309

12310+
let config: Config = toml::from_str(
12311+
r#"
12312+
[[vhosts]]
12313+
name = "gateway"
12314+
hosts = ["gateway.example"]
12315+
12316+
[[vhosts.routes]]
12317+
name = "bad"
12318+
path_prefix = "/one/"
12319+
strip_prefix = "/one/"
12320+
rewrite_prefix = "/upstream/./"
12321+
12322+
[vhosts.routes.proxy]
12323+
upstreams = ["127.0.0.1:6012"]
12324+
"#,
12325+
)
12326+
.unwrap();
12327+
12328+
assert_eq!(
12329+
config.validate(),
12330+
Err(ConfigError::InvalidRouteRewritePrefix {
12331+
vhost: "gateway".to_owned(),
12332+
route: "bad".to_owned(),
12333+
})
12334+
);
12335+
1231012336
let config: Config = toml::from_str(
1231112337
r#"
1231212338
[server]

crates/fluxheim-server/src/native_http1_plan.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ fn vhost_policy_supported(vhost: &VhostConfig) -> bool {
190190
}
191191

192192
fn route_policy_supported(route: &RouteConfig) -> bool {
193-
!access_policy_active(&route.access)
193+
route.path_regex.is_none()
194+
&& !access_policy_active(&route.access)
194195
&& !route.rate_limit.enabled
195196
&& !route.concurrency.enabled
196197
&& !route.grpc.enabled

crates/fluxheim-server/src/native_http1_plan_tests.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,63 @@ fn server_plan_rejects_native_http1_route_proxy_candidate_with_route_policy() {
175175
);
176176
}
177177

178+
#[test]
179+
fn server_plan_rejects_native_http1_route_proxy_candidate_with_regex_route() {
180+
let mut config = Config::default();
181+
config.server.regex_enabled = true;
182+
config.vhosts = vec![VhostConfig {
183+
name: "native.test".to_owned(),
184+
hosts: vec!["native.test".to_owned()],
185+
max_request_body_bytes: None,
186+
access: Default::default(),
187+
rate_limit: Default::default(),
188+
concurrency: Default::default(),
189+
tls: Default::default(),
190+
acme_challenge: Default::default(),
191+
redirect: Default::default(),
192+
proxy: fluxheim_config::ProxyConfig::disabled(),
193+
cache: CacheConfig::default(),
194+
compression: None,
195+
headers: Default::default(),
196+
php: Default::default(),
197+
web: Default::default(),
198+
routes: vec![RouteConfig {
199+
name: "api".to_owned(),
200+
path_exact: None,
201+
path_prefix: None,
202+
path_regex: Some("^/api/v[0-9]+/".to_owned()),
203+
methods: Vec::new(),
204+
fallback: false,
205+
https_redirect_exempt: false,
206+
strip_prefix: None,
207+
rewrite_prefix: None,
208+
rewrite_template: None,
209+
max_request_body_bytes: None,
210+
access: Default::default(),
211+
rate_limit: Default::default(),
212+
concurrency: Default::default(),
213+
grpc: Default::default(),
214+
redirect: None,
215+
proxy: Some(fluxheim_config::ProxyConfig {
216+
upstreams: vec!["127.0.0.1:3002".to_owned()],
217+
..Default::default()
218+
}),
219+
web: None,
220+
php: None,
221+
cache: None,
222+
compression: None,
223+
headers: Default::default(),
224+
}],
225+
}];
226+
227+
let plan = ServerPlan::from_config(&config).expect("valid server plan");
228+
229+
assert_eq!(
230+
plan.native_http1_proxy_candidates()[0].unsupported_reason(),
231+
Some(NativeHttp1ProxyConfigError::HttpPolicy)
232+
);
233+
}
234+
178235
#[test]
179236
fn server_plan_accepts_native_http1_route_proxy_candidate_with_prefix_rewrite() {
180237
let config = Config {

crates/fluxheim-server/src/native_http1_route_proxy_tests.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,16 @@ async fn native_route_proxy_rejects_unsafe_rewritten_path() {
154154
assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n"));
155155
assert!(response.ends_with("bad request\n"));
156156
}
157+
158+
#[tokio::test]
159+
async fn native_route_proxy_rejects_double_slash_after_stripped_prefix() {
160+
let upstream = upstream_expect_path("/never", "never").await;
161+
let route = NativeHttp1RouteProxyRoute::prefix("/api", Vec::new(), proxy_for(upstream))
162+
.with_strip_prefix("/api");
163+
let proxy = route_proxy_listener(NativeHttp1RouteProxy::new(vec![route], None)).await;
164+
165+
let response = downstream_get(proxy, "/api//evil").await;
166+
167+
assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n"));
168+
assert!(response.ends_with("bad request\n"));
169+
}

packaging/rpm/fluxheim.spec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ fi
158158
- Add native HTTP/1 proxy candidate rows to runtime cutover evidence.
159159
- Add the first native HTTP/1 route-proxy execution primitive for exact,
160160
prefix, and fallback routes with prefix strip/rewrite support.
161+
- Harden native route-proxy path validation, regex-route cutover reporting,
162+
candidate-row validation, and route path config validation.
161163
- Re-scope final Pingora dependency deletion to 1.6.28 after remaining native
162164
policy and rich proxy parity slices.
163165
- Update release metadata for 1.6.25.

release-notes/RELEASE_NOTES_1.6.25.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ for ordinary exact, prefix, and fallback proxy routes.
3131
while allowing candidate-detail rows for audit visibility.
3232
- Reject invalid native route-proxy request targets and unsafe rewritten paths
3333
before forwarding.
34+
- Reject ambiguous interior double-slash forward paths in the native
35+
route-proxy strip/rewrite path.
36+
- Keep regex routes marked as compatibility-required until native regex route
37+
matching is implemented.
38+
- Validate native HTTP/1 proxy candidate row shape in the runtime cutover gate
39+
before ignoring those rows for blocker status.
40+
- Reject single-dot route path segments during config validation.
3441
- Keep the dependency exception gate active so documented Pingora removal
3542
targets remain enforced by CI.
3643

scripts/validate-native-runtime-cutover.sh

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,13 @@ awk -F '\t' '
105105
}
106106
/^native-runtime-adapter:/ { next }
107107
/^config tester: ok$/ { next }
108-
$1 == "native-http1-proxy-candidate" { next }
108+
$1 == "native-http1-proxy-candidate" {
109+
if (NF != 4) {
110+
print "native runtime cutover evidence: malformed native-http1-proxy-candidate row: " $0 > "/dev/stderr"
111+
exit 2
112+
}
113+
next
114+
}
109115
$1 == "blocker" { next }
110116
NF == 0 { next }
111117
NF != 3 {
@@ -139,7 +145,13 @@ awk -F '\t' '
139145
}
140146
/^native-runtime-adapter:/ { next }
141147
/^config tester: ok$/ { next }
142-
$1 == "native-http1-proxy-candidate" { next }
148+
$1 == "native-http1-proxy-candidate" {
149+
if (NF != 4) {
150+
print "native runtime cutover evidence: malformed native-http1-proxy-candidate row: " $0 > "/dev/stderr"
151+
exit 2
152+
}
153+
next
154+
}
143155
$1 == "blocker" { next }
144156
NF == 0 { next }
145157
NF != 3 {
@@ -169,7 +181,13 @@ awk -F '\t' '
169181
awk -F '\t' '
170182
/^native-runtime-adapter:/ { next }
171183
/^config tester: ok$/ { next }
172-
$1 == "native-http1-proxy-candidate" { next }
184+
$1 == "native-http1-proxy-candidate" {
185+
if (NF != 4) {
186+
print "native runtime cutover evidence: malformed native-http1-proxy-candidate row: " $0 > "/dev/stderr"
187+
exit 2
188+
}
189+
next
190+
}
173191
$1 == "blocker" { next }
174192
NF == 0 { next }
175193
NF != 3 {

0 commit comments

Comments
 (0)