@@ -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" ) ]
31863205fn 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