Skip to content

Commit 87b1a1f

Browse files
committed
Harden static web path and timestamp handling
1 parent 00221c1 commit 87b1a1f

7 files changed

Lines changed: 130 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ behavior when the change improves security or project direction.
4141
its complete dedicated process group during shutdown and watchdog recovery.
4242
- Bound GeoIP database reads to an exact admitted-length allocation with a
4343
separate growth probe, and validate every publicly constructed `GeoContext`.
44+
- Make directory-listing timestamp formatting checked and preserve the
45+
`SafeRelativePath` invariant through validated incremental components,
46+
preventing panic-abort and latent traversal paths in static serving.
4447

4548
## 1.7.7 - 2026-07-10
4649

crates/fluxheim-server/src/native_http1_static_web_resolve.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ impl NativeHttp1StaticWeb {
4040
{
4141
return Ok(None);
4242
}
43-
relative.push(segment);
43+
if relative.try_push(segment).is_err() {
44+
return Ok(None);
45+
}
4446
}
4547

4648
Ok(Some(relative))

crates/fluxheim-web/src/directory_listing.rs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::path::Path;
2-
use std::time::SystemTime;
2+
use std::time::{SystemTime, UNIX_EPOCH};
33

44
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
55

@@ -51,13 +51,12 @@ pub fn render_directory_listing(listing: &DirectoryListing) -> String {
5151
.unwrap_or_else(|| "-".to_owned()),
5252
);
5353
html.push_str("</td><td>");
54-
if let Some(modified) = entry.modified {
55-
html.push_str(&html_escape(&format_directory_listing_time(
56-
modified,
57-
listing.local_time,
58-
)));
59-
} else {
60-
html.push('-');
54+
match entry
55+
.modified
56+
.and_then(|modified| format_directory_listing_time(modified, listing.local_time))
57+
{
58+
Some(timestamp) => html.push_str(&html_escape(&timestamp)),
59+
None => html.push('-'),
6160
}
6261
html.push_str("</td></tr>");
6362
}
@@ -82,13 +81,25 @@ pub fn directory_listing_path(relative: &Path) -> String {
8281
path
8382
}
8483

85-
fn format_directory_listing_time(modified: SystemTime, local_time: bool) -> String {
84+
fn format_directory_listing_time(modified: SystemTime, local_time: bool) -> Option<String> {
85+
const MAX_HTTP_DATE_SECONDS: u64 = 253_402_300_799;
86+
87+
let duration = modified.duration_since(UNIX_EPOCH).ok()?;
88+
if duration.as_secs() > MAX_HTTP_DATE_SECONDS {
89+
return None;
90+
}
8691
if local_time {
87-
let local: chrono::DateTime<chrono::Local> = modified.into();
88-
return local.format("%Y-%m-%d %H:%M:%S %z").to_string();
92+
let seconds = i64::try_from(duration.as_secs()).ok()?;
93+
let utc =
94+
chrono::DateTime::<chrono::Utc>::from_timestamp(seconds, duration.subsec_nanos())?;
95+
return Some(
96+
utc.with_timezone(&chrono::Local)
97+
.format("%Y-%m-%d %H:%M:%S %z")
98+
.to_string(),
99+
);
89100
}
90101

91-
httpdate::fmt_http_date(modified)
102+
Some(httpdate::fmt_http_date(modified))
92103
}
93104

94105
fn html_escape(value: &str) -> String {

crates/fluxheim-web/src/lib.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
deny(clippy::expect_used, clippy::panic, clippy::unwrap_used)
55
)]
66

7-
use std::ffi::OsString;
7+
use std::ffi::{OsStr, OsString};
88
use std::io;
99
use std::path::{Path, PathBuf};
1010
use std::time::SystemTime;
@@ -70,9 +70,30 @@ pub struct SafeRelativePath {
7070
components: Vec<OsString>,
7171
}
7272

73+
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
74+
pub struct InvalidPathComponent;
75+
76+
impl std::fmt::Display for InvalidPathComponent {
77+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78+
formatter.write_str("path component must be one non-empty normal component")
79+
}
80+
}
81+
82+
impl std::error::Error for InvalidPathComponent {}
83+
7384
impl SafeRelativePath {
74-
pub fn push(&mut self, component: &str) {
75-
self.components.push(OsString::from(component));
85+
pub fn try_push(&mut self, component: &str) -> Result<(), InvalidPathComponent> {
86+
if component.contains('\0') {
87+
return Err(InvalidPathComponent);
88+
}
89+
let mut components = Path::new(component).components();
90+
match (components.next(), components.next()) {
91+
(Some(std::path::Component::Normal(value)), None) if value == OsStr::new(component) => {
92+
self.components.push(value.to_os_string());
93+
Ok(())
94+
}
95+
_ => Err(InvalidPathComponent),
96+
}
7697
}
7798

7899
pub fn as_path(&self) -> PathBuf {

crates/fluxheim-web/src/web_tests.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::path::Path;
2-
use std::time::UNIX_EPOCH;
2+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
33

44
use fluxheim_common::test_support::{safe_child_path, unique_temp_path};
55

@@ -51,13 +51,80 @@ fn directory_listing_local_time_uses_local_timestamp_shape() {
5151
assert!(!html.contains("GMT"));
5252
}
5353

54+
#[test]
55+
fn directory_listing_rejects_pre_epoch_timestamps_without_panicking() {
56+
let modified = UNIX_EPOCH
57+
.checked_sub(Duration::from_secs(1))
58+
.expect("test platform should represent a pre-epoch timestamp");
59+
60+
for local_time in [false, true] {
61+
let html = render_listing_with_timestamp(modified, local_time);
62+
assert!(html.contains("<td>1</td><td>-</td>"), "{html}");
63+
}
64+
}
65+
66+
#[test]
67+
fn directory_listing_rejects_post_year_9999_timestamps_without_panicking() {
68+
let modified = UNIX_EPOCH
69+
.checked_add(Duration::from_secs(253_402_300_800))
70+
.expect("test platform should represent a post-year-9999 timestamp");
71+
72+
for local_time in [false, true] {
73+
let html = render_listing_with_timestamp(modified, local_time);
74+
assert!(html.contains("<td>1</td><td>-</td>"), "{html}");
75+
}
76+
}
77+
78+
fn render_listing_with_timestamp(modified: SystemTime, local_time: bool) -> String {
79+
render_directory_listing(&DirectoryListing {
80+
path: "/".to_owned(),
81+
entries: vec![DirectoryEntry {
82+
name: "timestamp.txt".to_owned(),
83+
is_dir: false,
84+
size: Some(1),
85+
modified: Some(modified),
86+
}],
87+
local_time,
88+
})
89+
}
90+
5491
#[test]
5592
fn safe_relative_path_rejects_non_normal_components() {
5693
assert!(SafeRelativePath::from_path(Path::new("assets/app.css")).is_some());
5794
assert!(SafeRelativePath::from_path(Path::new("../secret")).is_none());
5895
assert!(SafeRelativePath::from_path(Path::new("/absolute")).is_none());
5996
}
6097

98+
#[test]
99+
fn safe_relative_path_try_push_preserves_component_invariant() {
100+
let mut path = SafeRelativePath::default();
101+
path.try_push("assets")
102+
.expect("normal component should pass");
103+
104+
for invalid in [
105+
"",
106+
".",
107+
"..",
108+
"../secret",
109+
"a/b",
110+
"/absolute",
111+
"trailing/",
112+
"nul\0byte",
113+
] {
114+
assert!(path.try_push(invalid).is_err(), "accepted {invalid:?}");
115+
}
116+
assert_eq!(path.as_path(), Path::new("assets"));
117+
}
118+
119+
#[cfg(windows)]
120+
#[test]
121+
fn safe_relative_path_try_push_rejects_windows_separators() {
122+
let mut path = SafeRelativePath::default();
123+
assert!(path.try_push(r"parent\child").is_err());
124+
assert!(path.try_push(r"C:\absolute").is_err());
125+
assert_eq!(path.as_path(), Path::new(""));
126+
}
127+
61128
#[test]
62129
fn safe_relative_path_detects_prefixed_components() {
63130
let path = SafeRelativePath::from_path(Path::new(".well-known/acme-challenge"))

release-notes/RELEASE_NOTES_1.7.8.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ general-purpose WASI application hosting.
6868
observe malformed security state.
6969
- Replace inherited managed PHP-FPM `PATH` handling with a fixed allowlisted
7070
search path after clearing the child environment.
71+
- Render unavailable directory-listing timestamps as `-` after checked epoch
72+
and year-9999 bounds, preventing attacker-influenced file metadata from
73+
reaching panic-prone timestamp formatters in release builds.
74+
- Replace unchecked `SafeRelativePath` component insertion with a validating
75+
single-normal-component API so the public type preserves its traversal-safety
76+
invariant for current and future static-serving callers.
7177

7278
## Validation
7379

src/web.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ impl StaticFileServer {
157157
return Ok(None);
158158
}
159159

160-
relative.push(segment);
160+
if relative.try_push(segment).is_err() {
161+
return Ok(None);
162+
}
161163
}
162164

163165
Ok(Some(relative))

0 commit comments

Comments
 (0)