@@ -4,7 +4,10 @@ use async_trait::async_trait;
44use glob:: Pattern ;
55use serde:: { Deserialize , Serialize } ;
66
7- use super :: path_normalization:: { normalize_path_for_policy, normalize_path_for_policy_with_fs} ;
7+ use super :: path_normalization:: {
8+ normalize_path_for_policy, normalize_path_for_policy_lexical_absolute,
9+ normalize_path_for_policy_with_fs,
10+ } ;
811use super :: { Guard , GuardAction , GuardContext , GuardResult , Severity } ;
912
1013/// Configuration for ForbiddenPathGuard
@@ -191,16 +194,28 @@ impl ForbiddenPathGuard {
191194 pub fn is_forbidden ( & self , path : & str ) -> bool {
192195 let lexical_path = normalize_path_for_policy ( path) ;
193196 let resolved_path = normalize_path_for_policy_with_fs ( path) ;
194- let resolved_differs = resolved_path != lexical_path;
197+ let lexical_abs_path = normalize_path_for_policy_lexical_absolute ( path) ;
198+ let resolved_differs_from_lexical_target = lexical_abs_path
199+ . as_deref ( )
200+ . map ( |abs| abs != resolved_path. as_str ( ) )
201+ . unwrap_or ( resolved_path != lexical_path) ;
195202
196203 // Check exceptions first
197204 for exception in & self . exceptions {
198- let exception_matches = if resolved_differs {
199- // If resolution changed the path (e.g., via symlink/canonical host mount aliases),
200- // require the exception to match the resolved target to avoid lexical bypasses.
201- exception. matches ( & resolved_path)
205+ let lexical_matches = exception. matches ( & lexical_path)
206+ || lexical_abs_path
207+ . as_deref ( )
208+ . map ( |abs| exception. matches ( abs) )
209+ . unwrap_or ( false ) ;
210+ let resolved_matches = exception. matches ( & resolved_path) ;
211+ let exception_matches = if resolved_differs_from_lexical_target {
212+ // If resolution changed the actual target (for example via symlink traversal),
213+ // require the exception to match the resolved target to prevent lexical bypasses.
214+ resolved_matches
202215 } else {
203- exception. matches ( & lexical_path)
216+ // If target identity is unchanged (for example relative -> absolute conversion),
217+ // allow either lexical or resolved exception forms.
218+ resolved_matches || lexical_matches
204219 } ;
205220
206221 if exception_matches {
@@ -309,6 +324,29 @@ mod tests {
309324 assert ! ( !guard. is_forbidden( "/app/project/.env" ) ) ;
310325 }
311326
327+ #[ test]
328+ fn relative_exception_matches_when_target_is_unchanged ( ) {
329+ let rel_dir = format ! ( "target/forbidden-path-rel-{}" , uuid:: Uuid :: new_v4( ) ) ;
330+ std:: fs:: create_dir_all ( & rel_dir) . expect ( "create rel dir" ) ;
331+ let rel_file = format ! ( "{rel_dir}/.env" ) ;
332+ std:: fs:: write ( & rel_file, "API_KEY=test\n " ) . expect ( "write file" ) ;
333+
334+ let guard = ForbiddenPathGuard :: with_config ( ForbiddenPathConfig {
335+ enabled : true ,
336+ patterns : Some ( vec ! [ "**/.env" . to_string( ) ] ) ,
337+ exceptions : vec ! [ rel_file. clone( ) ] ,
338+ additional_patterns : vec ! [ ] ,
339+ remove_patterns : vec ! [ ] ,
340+ } ) ;
341+
342+ assert ! (
343+ !guard. is_forbidden( & rel_file) ,
344+ "relative exception should match even when fs normalization produces absolute path"
345+ ) ;
346+
347+ let _ = std:: fs:: remove_dir_all ( & rel_dir) ;
348+ }
349+
312350 #[ test]
313351 fn test_additional_patterns_field ( ) {
314352 let yaml = r#"
@@ -398,4 +436,36 @@ remove_patterns:
398436
399437 let _ = std:: fs:: remove_dir_all ( & root) ;
400438 }
439+
440+ #[ cfg( unix) ]
441+ #[ test]
442+ fn lexical_exception_does_not_bypass_forbidden_resolved_target ( ) {
443+ use std:: os:: unix:: fs:: symlink;
444+
445+ let root = std:: env:: temp_dir ( ) . join ( format ! ( "forbidden-path-{}" , uuid:: Uuid :: new_v4( ) ) ) ;
446+ let safe_dir = root. join ( "safe" ) ;
447+ let forbidden_dir = root. join ( "forbidden" ) ;
448+ std:: fs:: create_dir_all ( & safe_dir) . expect ( "create safe dir" ) ;
449+ std:: fs:: create_dir_all ( & forbidden_dir) . expect ( "create forbidden dir" ) ;
450+
451+ let target = forbidden_dir. join ( "secret.env" ) ;
452+ std:: fs:: write ( & target, "secret" ) . expect ( "write target" ) ;
453+ let link = safe_dir. join ( "project.env" ) ;
454+ symlink ( & target, & link) . expect ( "create symlink" ) ;
455+
456+ let guard = ForbiddenPathGuard :: with_config ( ForbiddenPathConfig {
457+ enabled : true ,
458+ patterns : Some ( vec ! [ "**/forbidden/**" . to_string( ) ] ) ,
459+ exceptions : vec ! [ "**/safe/project.env" . to_string( ) ] ,
460+ additional_patterns : vec ! [ ] ,
461+ remove_patterns : vec ! [ ] ,
462+ } ) ;
463+
464+ assert ! (
465+ guard. is_forbidden( link. to_str( ) . expect( "utf-8 path" ) ) ,
466+ "lexical-only exception should not bypass when resolved target is forbidden"
467+ ) ;
468+
469+ let _ = std:: fs:: remove_dir_all ( & root) ;
470+ }
401471}
0 commit comments