Skip to content

Commit 7b66144

Browse files
committed
Harden admin throttle and cache purge paths
1 parent 658602a commit 7b66144

3 files changed

Lines changed: 53 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ behavior when the change improves security or project direction.
1414
- Harden path forwarding safety against triple-encoded traversal segments,
1515
zeroize copied auth-request forwarded header values, and rename the private
1616
admin empty-path validator to avoid confusion with full path-safety checks.
17+
- Harden admin auth throttling so a full per-source table evicts the stalest
18+
tracked source instead of immediately promoting a new source to global
19+
lockout, and route cache purge path validation through the shared path-safety
20+
decoder.
1721
- Start the config module split and maintenance architecture release. This
1822
release is intentionally scoped to behavior-preserving extraction of the
1923
large `config.rs` surface into focused loading, validation, and domain slices

release-notes/RELEASE_NOTES_1.4.3.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ focused modules before the next `1.4.x` policy features.
2525
percent-decode pass, while still bounding decode work.
2626
- Auth-request copied forwarded header values now use zeroizing storage after
2727
the subrequest completes.
28+
- Admin auth throttling now evicts the stalest tracked source when the
29+
per-source table is full instead of immediately escalating a new source to a
30+
global lockout.
31+
- Cache purge path validation now uses the same shared multi-pass encoded path
32+
safety check as proxy forwarding paths.
2833
- A private admin path helper was renamed so maintainers do not confuse the
2934
empty-path check with full traversal/symlink path validation.
3035

src/admin.rs

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,16 @@ impl AdminAuthThrottle {
239239
if !state.ensure_source_capacity(&self.config, source) {
240240
log::warn!(
241241
target: "fluxheim::security",
242-
"admin auth throttle source table full; applying global lockout"
242+
"admin auth throttle source table full; applying global failure accounting"
243243
);
244-
state.global_lockouts = state.global_lockouts.saturating_add(1);
245-
state.global_locked_until =
246-
now.saturating_add(lockout_secs(&self.config, state.global_lockouts));
247-
state.global_failures.clear();
248-
return Some(AdminAuthThrottleScope::Global);
244+
if state.global_failures.len() >= self.config.global_failures {
245+
state.global_lockouts = state.global_lockouts.saturating_add(1);
246+
state.global_locked_until =
247+
now.saturating_add(lockout_secs(&self.config, state.global_lockouts));
248+
state.global_failures.clear();
249+
return Some(AdminAuthThrottleScope::Global);
250+
}
251+
return None;
249252
}
250253

251254
let source_locked = {
@@ -305,6 +308,26 @@ impl AdminAuthThrottleState {
305308
if self.sources.contains_key(&source) || self.sources.len() < config.max_sources {
306309
return true;
307310
}
311+
if let Some(stale_key) = self
312+
.sources
313+
.iter()
314+
.filter(|(_, record)| record.locked_until == 0)
315+
.min_by_key(|(_, record)| record.last_seen)
316+
.map(|(source, _)| *source)
317+
.or_else(|| {
318+
self.sources
319+
.iter()
320+
.min_by_key(|(_, record)| record.last_seen)
321+
.map(|(source, _)| *source)
322+
})
323+
{
324+
self.sources.remove(&stale_key);
325+
log::warn!(
326+
target: "fluxheim::security",
327+
"admin auth throttle source table full; evicted stale source entry"
328+
);
329+
return true;
330+
}
308331
false
309332
}
310333
}
@@ -3116,7 +3139,9 @@ fn validate_cache_purge_path_value(path: &str) -> Result<(), &'static str> {
31163139
{
31173140
return Err("cache purge path is invalid");
31183141
}
3119-
if path_contains_traversal_segment(path) || path_contains_encoded_path_control(path) {
3142+
if path_contains_traversal_segment(path)
3143+
|| !crate::path_safety::safe_forward_path_and_query(path)
3144+
{
31203145
return Err("cache purge path must not contain traversal segments");
31213146
}
31223147
Ok(())
@@ -3176,12 +3201,6 @@ fn path_contains_traversal_segment(path: &str) -> bool {
31763201
path.split('/').any(|segment| matches!(segment, "." | ".."))
31773202
}
31783203

3179-
#[cfg(feature = "cache")]
3180-
fn path_contains_encoded_path_control(path: &str) -> bool {
3181-
let lower = path.to_ascii_lowercase();
3182-
lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c")
3183-
}
3184-
31853204
#[cfg(feature = "cache")]
31863205
fn validated_cache_purge_query(query: Option<&str>) -> Result<Option<&str>, &'static str> {
31873206
let Some(query) = query.filter(|query| !query.is_empty()) else {
@@ -3269,9 +3288,9 @@ mod tests {
32693288
use http::{HeaderMap, HeaderValue, StatusCode, header};
32703289

32713290
use super::{
3272-
AdminApp, AdminAuthThrottle, AdminAuthThrottleScope, AdminRuntimeState, AdminToken,
3273-
MAX_ADMIN_TOKEN_FILE_BYTES, admin_services_from_config, authorized, constant_time_eq,
3274-
error_response, json_response, read_bounded_secret_file, read_secret_file,
3291+
AdminApp, AdminAuthThrottle, AdminRuntimeState, AdminToken, MAX_ADMIN_TOKEN_FILE_BYTES,
3292+
admin_services_from_config, authorized, constant_time_eq, error_response, json_response,
3293+
read_bounded_secret_file, read_secret_file,
32753294
};
32763295
use crate::config::{
32773296
AdminAuthThrottleConfig, AdminClientCertificateConfig, AdminConfig, AdminHealthConfig,
@@ -3529,7 +3548,7 @@ mod tests {
35293548
}
35303549

35313550
#[test]
3532-
fn admin_auth_throttle_locks_globally_when_source_table_is_full() {
3551+
fn admin_auth_throttle_evicts_stale_source_when_source_table_is_full() {
35333552
let throttle = AdminAuthThrottle::new(AdminAuthThrottleConfig {
35343553
enabled: true,
35353554
window_secs: 60,
@@ -3546,11 +3565,15 @@ mod tests {
35463565
);
35473566
assert_eq!(
35483567
throttle.record_failure(Some("192.0.2.31".parse().unwrap())),
3549-
Some(AdminAuthThrottleScope::Global)
3568+
None
35503569
);
35513570
assert_eq!(
35523571
throttle.pre_auth_check(Some("192.0.2.30".parse().unwrap())),
3553-
Some(AdminAuthThrottleScope::Global)
3572+
None
3573+
);
3574+
assert_eq!(
3575+
throttle.record_failure(Some("192.0.2.31".parse().unwrap())),
3576+
None
35543577
);
35553578
}
35563579

@@ -4709,6 +4732,8 @@ mod tests {
47094732
let cases = [
47104733
Some("host=example.test&path=/../secret.png"),
47114734
Some("host=example.test&path=/img/%2e%2e/secret.png"),
4735+
Some("host=example.test&path=/img/%252e%252e/secret.png"),
4736+
Some("host=example.test&path=/img/%25252e%25252e/secret.png"),
47124737
Some("host=example.test&path=/img\\secret.png"),
47134738
Some("host=example.test&method=GET POST&path=/img/logo.png"),
47144739
Some("host=example.test/evil&path=/img/logo.png"),

0 commit comments

Comments
 (0)