Skip to content

Commit d8f7ac7

Browse files
committed
Harden native static and redirect handling
1 parent cb28171 commit d8f7ac7

8 files changed

Lines changed: 202 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,16 @@ behavior when the change improves security or project direction.
3838
containment model in the native static-web route adapter.
3939
- Reject percent-decoded dot-segment, backslash, NUL, and denied-dotfile paths
4040
before static files are resolved.
41-
- Re-open static response bodies only after confirming the requested path is
42-
still rooted under the configured web root and is still a regular file.
41+
- Open static response bodies with rooted component-by-component `openat`
42+
calls and no-symlink flags, closing the symlink-swap window between metadata
43+
checks and body reads.
44+
- Return `405 Method Not Allowed` from the native static-web handler for
45+
methods other than `GET` and `HEAD`, even when the route method list matches
46+
all methods.
47+
- Validate native redirect `Location` URL paths with the shared bounded
48+
multi-pass forward-path safety check so encoded and double-encoded dot
49+
segments or slashes cannot be introduced through `{query}`, `{path}`, or
50+
`{uri}` expansion.
4351
- Bound native buffered static responses to 64 MiB until the final native
4452
streaming body path is enabled.
4553
- Keep forwarded-client-IP ownership shortcuts on the compatibility path while

crates/fluxheim-server/src/native_http1_route_proxy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ fn redirect_location_path_safe(location: &str) -> bool {
579579
.find(['?', '#'])
580580
.unwrap_or(path_and_tail.len());
581581
let path = &path_and_tail[..path_end];
582-
!path.contains("//") && !path.split('/').any(|segment| matches!(segment, "." | ".."))
582+
path.is_empty() || safe_forward_path(path)
583583
}
584584

585585
impl NativeRouteRequestHeaderPolicy {

crates/fluxheim-server/src/native_http1_route_proxy_tests.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,38 @@ async fn native_route_proxy_redirect_rejects_query_path_traversal_expansion() {
304304
assert!(response.ends_with("invalid redirect target\n"));
305305
}
306306

307+
#[tokio::test]
308+
async fn native_route_proxy_redirect_rejects_percent_encoded_query_path_traversal_expansion() {
309+
let route = NativeHttp1RouteProxyRoute::exact_redirect(
310+
"/file",
311+
Vec::new(),
312+
"https://cdn.example/files/{query}",
313+
302,
314+
);
315+
let proxy = route_proxy_listener(NativeHttp1RouteProxy::new(vec![route], None)).await;
316+
317+
let response = downstream_get(proxy, "/file?%2e%2e/%2e%2e/admin/secrets").await;
318+
319+
assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n"));
320+
assert!(response.ends_with("invalid redirect target\n"));
321+
}
322+
323+
#[tokio::test]
324+
async fn native_route_proxy_redirect_rejects_double_encoded_query_path_traversal_expansion() {
325+
let route = NativeHttp1RouteProxyRoute::exact_redirect(
326+
"/file",
327+
Vec::new(),
328+
"https://cdn.example/files/{query}",
329+
302,
330+
);
331+
let proxy = route_proxy_listener(NativeHttp1RouteProxy::new(vec![route], None)).await;
332+
333+
let response = downstream_get(proxy, "/file?%252e%252e/secret").await;
334+
335+
assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n"));
336+
assert!(response.ends_with("invalid redirect target\n"));
337+
}
338+
307339
#[tokio::test]
308340
async fn native_route_proxy_redirect_rejects_double_slash_location_path() {
309341
let route = NativeHttp1RouteProxyRoute::exact_redirect(
@@ -320,6 +352,22 @@ async fn native_route_proxy_redirect_rejects_double_slash_location_path() {
320352
assert!(response.ends_with("invalid redirect target\n"));
321353
}
322354

355+
#[tokio::test]
356+
async fn native_route_proxy_redirect_rejects_percent_encoded_double_slash_location_path() {
357+
let route = NativeHttp1RouteProxyRoute::exact_redirect(
358+
"/file",
359+
Vec::new(),
360+
"https://cdn.example/files/{query}",
361+
302,
362+
);
363+
let proxy = route_proxy_listener(NativeHttp1RouteProxy::new(vec![route], None)).await;
364+
365+
let response = downstream_get(proxy, "/file?%2f%2fadmin").await;
366+
367+
assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n"));
368+
assert!(response.ends_with("invalid redirect target\n"));
369+
}
370+
323371
#[tokio::test]
324372
async fn native_route_proxy_rejects_route_body_over_limit() {
325373
let route = NativeHttp1RouteProxyRoute::exact(

crates/fluxheim-server/src/native_http1_route_static_web_tests.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ async fn downstream_get(proxy: std::net::SocketAddr, path: &str) -> String {
4242
.await
4343
}
4444

45+
async fn downstream_post(proxy: std::net::SocketAddr, path: &str) -> String {
46+
downstream_request(
47+
proxy,
48+
&format!("POST {path} HTTP/1.1\r\nHost: route.test\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"),
49+
)
50+
.await
51+
}
52+
4553
async fn downstream_request(proxy: std::net::SocketAddr, request: &str) -> String {
4654
let mut client = TcpStream::connect(proxy).await.unwrap();
4755
client.write_all(request.as_bytes()).await.unwrap();
@@ -126,6 +134,30 @@ async fn native_route_proxy_serves_static_web_range() {
126134
root.close().unwrap();
127135
}
128136

137+
#[tokio::test]
138+
async fn native_route_proxy_static_web_rejects_non_get_head_method() {
139+
let root = TempDir::new().unwrap();
140+
fs::write(root.path().join("asset.txt"), b"native-static\n").unwrap();
141+
let route = NativeHttp1RouteProxyRoute::prefix_static_web(
142+
"/files/",
143+
Vec::new(),
144+
native_static_web(root.path()),
145+
)
146+
.with_strip_prefix("/files/");
147+
let proxy = route_proxy_listener(NativeHttp1RouteProxy::new(vec![route], None)).await;
148+
149+
let response = downstream_post(proxy, "/files/asset.txt").await;
150+
151+
assert!(response.starts_with("HTTP/1.1 405 Method Not Allowed\r\n"));
152+
assert_eq!(
153+
response_header(&response, "allow").as_deref(),
154+
Some("GET, HEAD")
155+
);
156+
assert!(response.ends_with("method not allowed\n"));
157+
158+
root.close().unwrap();
159+
}
160+
129161
#[tokio::test]
130162
async fn native_route_proxy_static_web_rejects_traversal() {
131163
let root = TempDir::new().unwrap();

crates/fluxheim-server/src/native_http1_static_web.rs

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::fs::File;
22
use std::io::{self, Read, Seek};
3-
use std::path::{Path, PathBuf};
3+
use std::path::{Component, Path, PathBuf};
44
use std::time::SystemTime;
55

66
use fluxheim_config::{DirectoryListingConfig, WebConfig};
@@ -90,6 +90,12 @@ impl NativeHttp1StaticWeb {
9090
}
9191

9292
pub fn handle(&self, request: &NativeHttp1Request, request_path: &str) -> NativeHttp1Response {
93+
if !static_web_method_allowed(&request.method) {
94+
return NativeHttp1Response::new(405, "Method Not Allowed", b"method not allowed\n")
95+
.with_header("allow", "GET, HEAD")
96+
.close_connection();
97+
}
98+
9399
match self.resolve(request_path) {
94100
Ok(NativeStaticResolve::Found(file)) => self.file_response(request, &file),
95101
Ok(NativeStaticResolve::DirectoryListing(listing)) => {
@@ -409,14 +415,61 @@ fn open_static_body_file(file: &NativeStaticFile) -> io::Result<File> {
409415
"static body path escaped web root",
410416
));
411417
}
412-
let metadata = std::fs::symlink_metadata(&canonical)?;
413-
if metadata.file_type().is_symlink() || !metadata.is_file() {
418+
let opened = open_static_body_file_at_root(file, &relative.as_path())?;
419+
let metadata = opened.metadata()?;
420+
if !metadata.is_file() {
414421
return Err(io::Error::new(
415422
io::ErrorKind::InvalidInput,
416423
"static body path is not a regular file",
417424
));
418425
}
419-
File::open(canonical)
426+
Ok(opened)
427+
}
428+
429+
fn open_static_body_file_at_root(
430+
file: &NativeStaticFile,
431+
relative_path: &Path,
432+
) -> io::Result<File> {
433+
let directory_flags =
434+
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC;
435+
let nofollow_directory_flags = directory_flags | rustix::fs::OFlags::NOFOLLOW;
436+
let file_flags =
437+
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::NOFOLLOW;
438+
439+
let mut directory = rustix::fs::open(
440+
&file.root,
441+
nofollow_directory_flags,
442+
rustix::fs::Mode::empty(),
443+
)
444+
.map_err(io::Error::from)?;
445+
let mut components = relative_path.components().peekable();
446+
while let Some(component) = components.next() {
447+
let Component::Normal(name) = component else {
448+
return Err(io::Error::new(
449+
io::ErrorKind::InvalidInput,
450+
"static body path is not relative",
451+
));
452+
};
453+
let name = Path::new(name);
454+
if components.peek().is_some() {
455+
directory = rustix::fs::openat(
456+
&directory,
457+
name,
458+
nofollow_directory_flags,
459+
rustix::fs::Mode::empty(),
460+
)
461+
.map_err(io::Error::from)?;
462+
} else {
463+
let file = rustix::fs::openat(&directory, name, file_flags, rustix::fs::Mode::empty())
464+
.map_err(io::Error::from)?;
465+
return Ok(File::from(file));
466+
}
467+
}
468+
469+
Err(io::Error::new(
470+
io::ErrorKind::InvalidInput,
471+
"static body path is empty",
472+
))
420473
}
421474

422475
fn directory_listing_response(
@@ -436,6 +489,10 @@ fn directory_listing_response(
436489
.with_header("cache-control", "private, no-store")
437490
}
438491

492+
fn static_web_method_allowed(method: &str) -> bool {
493+
matches!(method, "GET" | "HEAD")
494+
}
495+
439496
fn static_reason(status: u16) -> &'static str {
440497
match status {
441498
200 => "OK",
@@ -470,3 +527,36 @@ fn content_type_for_path(path: &Path) -> &'static str {
470527
_ => "application/octet-stream",
471528
}
472529
}
530+
531+
#[cfg(test)]
532+
mod tests {
533+
use std::time::SystemTime;
534+
535+
use tempfile::TempDir;
536+
537+
use super::{NativeStaticFile, open_static_body_file};
538+
539+
#[cfg(unix)]
540+
#[test]
541+
fn open_static_body_file_rejects_symlink_swapped_after_resolution() {
542+
let root = TempDir::new().unwrap();
543+
let asset = root.path().join("asset.txt");
544+
let outside = root.path().join("outside.txt");
545+
std::fs::write(&asset, b"safe").unwrap();
546+
std::fs::write(&outside, b"outside").unwrap();
547+
let root_path = root.path().canonicalize().unwrap();
548+
let file = NativeStaticFile {
549+
root: root_path.clone(),
550+
path: root_path.join("asset.txt"),
551+
mime: "text/plain; charset=utf-8",
552+
len: 4,
553+
modified: Some(SystemTime::UNIX_EPOCH),
554+
};
555+
556+
std::fs::remove_file(&asset).unwrap();
557+
std::os::unix::fs::symlink(&outside, &asset).unwrap();
558+
559+
assert!(open_static_body_file(&file).is_err());
560+
root.close().unwrap();
561+
}
562+
}

docs/modularity-exceptions.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ documents why the exception is temporary and how it will be removed.
5050
| `crates/fluxheim-config/src/reload.rs` | 809 | Reload classification and diff behavior. | Move snapshot/reload-safe classification into dedicated modules. |
5151
| `crates/fluxheim-php-fpm/src/lib.rs` | 778 | PHP-FPM crate root still holds several pure domains. | Split params, response, retry, and managed runtime helpers. |
5252
| `crates/fluxheim-config/src/config_admin.rs` | 755 | Admin config and validation. | Split ops socket, snapshots, auth, and status config. |
53-
| `crates/fluxheim-load-balancer/src/persistence.rs` | 753 | Persistence, cookies, and request-view helpers. | Split cookie, header, source-IP, and state table helpers. |
53+
| `crates/fluxheim-load-balancer/src/persistence.rs` | 769 | Persistence, cookies, constant-time managed-cookie checks, and request-view helpers. | Split cookie, header, source-IP, and state table helpers. |
5454
| `crates/fluxheim-config/src/config_acme.rs` | 719 | ACME config and validation. | Move with `fluxheim-acme`. |
5555
| `src/config_tester.rs` | 716 | Config tester CLI and profile logic. | Split profile checks from CLI output. |
5656
| `crates/fluxheim-web/src/lib.rs` | 708 | Static web crate root. | Split range, directory listing, path safety, and response planning. |
@@ -65,8 +65,9 @@ documents why the exception is temporary and how it will be removed.
6565
| `crates/fluxheim-server/src/native_http1_tests.rs` | 605 | Native downstream HTTP/1 listener tests temporarily group request parsing, keep-alive, timeout, body, and listener-budget fixtures for parity review. | Split listener, request-body, timeout, and response-framing tests after the native HTTP/1 API stabilizes. |
6666
| `crates/fluxheim-server/src/server_tests.rs` | 567 | Server-plan and runtime-cutover policy tests grew during the `1.6.19` native TLS/listener proof work. | Split listener inventory, native-runtime blockers, HTTP policy mapping, and background service tests into focused modules. |
6767
| `crates/fluxheim-server/src/native_http1_client_tests.rs` | 539 | Native HTTP/1 upstream client tests temporarily group response parsing, pooling, stale-connection retry, timeout, and forwarded-header fixtures. | Split client pooling/retry tests from response/timeout/header tests as the native upstream client stabilizes. |
68-
| `crates/fluxheim-server/src/native_http1_route_proxy_tests.rs` | 525 | Native route-proxy tests temporarily group route matching, redirects, request/response header overlays, response rewrites, and route static-web fixtures for the rich route cutover slices. | Split redirect, header-policy, response-rewrite, and static-web route tests before final native runtime cutover. |
68+
| `crates/fluxheim-server/src/native_http1_route_proxy_tests.rs` | 573 | Native route-proxy tests temporarily group route matching, safe redirect expansion including encoded and double-encoded path hardening, request/response header overlays, response rewrites, and route static-web fixtures for the rich route cutover slices. | Split redirect, header-policy, response-rewrite, and static-web route tests before final native runtime cutover. |
6969
| `crates/fluxheim-server/src/native_http1_plan_tests.rs` | 838 | Native HTTP/1 cutover planning tests grew during the `1.6.26`-`1.6.27` route/policy parity slices to cover request-header mutations, response-header overlays, response rewrites, static weights, and redirect-shadowed route proxies together. | Split route-policy candidate tests from vhost/root candidate tests before the final Pingora dependency deletion. |
70-
| `crates/fluxheim-server/src/native_http1_route_proxy.rs` | 767 | Native route proxy temporarily groups route matching, safe redirect expansion, request-body limits, request/response-header overlays, response rewrites, and static-web route action dispatch during the `1.6.26`-`1.6.27` route/rich-integration parity slices. | Split redirect handling, header policy helpers, response rewrite helpers, and static-web dispatch into focused modules before final native runtime cutover. |
70+
| `crates/fluxheim-server/src/native_http1_route_proxy.rs` | 767 | Native route proxy temporarily groups route matching, safe redirect expansion including multi-pass path validation, request-body limits, request/response-header overlays, response rewrites, and static-web route action dispatch during the `1.6.26`-`1.6.27` route/rich-integration parity slices. | Split redirect handling, header policy helpers, response rewrite helpers, and static-web dispatch into focused modules before final native runtime cutover. |
71+
| `crates/fluxheim-server/src/native_http1_static_web.rs` | 562 | Native static-web route adapter groups path resolution, directory listing, static response planning, method enforcement, and rooted no-symlink body opening for the `1.6.27` security-reviewed route static-web slice. | Split path resolution/opening, directory listing, and response planning helpers before final native runtime cutover. |
7172
| `src/edge_policy.rs` | 532 | Edge access/rate/geo policy. | Split policy decisions and runtime proof types. |
7273
| `crates/fluxheim-cache/src/request.rs` | 545 | Cache request/range/slice planning plus native cache key identity. | Split key identity and range/slice planning if it grows further. |

packaging/rpm/fluxheim.spec

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,10 @@ fi
165165
- Apply static proxy.upstream_weights through native weighted round-robin.
166166
- Apply route-level native response rewrites for Location, Refresh, and
167167
Set-Cookie.
168-
- Harden native static path containment, symlink rejection, and body-open
169-
revalidation.
168+
- Harden native static path containment with rooted no-symlink body opens and
169+
method enforcement for non-GET/HEAD requests.
170+
- Harden native redirect Location validation for encoded and double-encoded
171+
dot segments and slashes produced by template expansion.
170172
- Keep forwarded-client-IP shortcut ownership on the compatibility path until
171173
native parity tests cover it.
172174
- Keep health-aware, persistence, priority-group, backup/drain, dynamic

release-notes/RELEASE_NOTES_1.6.27.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ web serving onto the native HTTP/1 route adapter.
2727

2828
- Native static-web path resolution rejects decoded dot segments, NUL bytes,
2929
backslashes, denied dotfiles, and symlink escapes.
30-
- Static response body reads re-check the rooted path and regular-file status
31-
before opening the file.
30+
- Static response body reads use rooted component-by-component `openat` with
31+
no-symlink opens for every directory component and the final file, closing the
32+
symlink-swap window between metadata checks and body reads.
33+
- Native route static-web handling now rejects methods other than `GET` and
34+
`HEAD` with `405 Method Not Allowed`, even when the route method list matches
35+
all methods.
36+
- Native redirect `Location` path validation now reuses the bounded multi-pass
37+
forward-path safety check, rejecting single- and double-encoded dot-segment
38+
or slash expansions from `{query}`, `{path}`, or `{uri}` templates.
3239
- Buffered native static responses are capped at 64 MiB until the final native
3340
streaming body path is completed.
3441
- Forwarded-client-IP shortcut ownership remains a compatibility-path blocker;

0 commit comments

Comments
 (0)