@@ -135,19 +135,24 @@ impl FilesystemIrm {
135135
136136 /// Extract path from host call arguments
137137 fn extract_path ( & self , call : & HostCall ) -> Option < String > {
138+ // Prefer explicit object path fields over free-form string args.
138139 for arg in & call. args {
139- if let Some ( s) = arg. as_str ( ) {
140- if self . looks_like_path ( s) {
141- return Some ( s. to_string ( ) ) ;
140+ if let Some ( obj) = arg. as_object ( ) {
141+ for key in [ "path" , "file_path" , "target_path" ] {
142+ if let Some ( path) = obj. get ( key) . and_then ( |value| value. as_str ( ) ) {
143+ let trimmed = path. trim ( ) ;
144+ if !trimmed. is_empty ( ) {
145+ return Some ( trimmed. to_string ( ) ) ;
146+ }
147+ }
142148 }
143149 }
144150 }
145151
146- // Check for named path argument
147- if let Some ( first) = call. args . first ( ) {
148- if let Some ( obj) = first. as_object ( ) {
149- if let Some ( path) = obj. get ( "path" ) {
150- return path. as_str ( ) . map ( |s| s. to_string ( ) ) ;
152+ for arg in & call. args {
153+ if let Some ( s) = arg. as_str ( ) {
154+ if self . looks_like_path ( s) {
155+ return Some ( s. to_string ( ) ) ;
151156 }
152157 }
153158 }
@@ -172,7 +177,36 @@ impl FilesystemIrm {
172177 return true ;
173178 }
174179
175- value. contains ( '/' ) && !value. contains ( "://" )
180+ value. contains ( '/' ) && !value. contains ( "://" ) && !self . looks_like_mime_type ( value)
181+ }
182+
183+ fn looks_like_mime_type ( & self , value : & str ) -> bool {
184+ let mut parts = value. split ( '/' ) ;
185+ let Some ( kind) = parts. next ( ) else {
186+ return false ;
187+ } ;
188+ let Some ( subtype) = parts. next ( ) else {
189+ return false ;
190+ } ;
191+ if parts. next ( ) . is_some ( ) {
192+ return false ;
193+ }
194+ if kind. is_empty ( ) || subtype. is_empty ( ) {
195+ return false ;
196+ }
197+
198+ matches ! (
199+ kind. to_ascii_lowercase( ) . as_str( ) ,
200+ "application"
201+ | "audio"
202+ | "font"
203+ | "image"
204+ | "message"
205+ | "model"
206+ | "multipart"
207+ | "text"
208+ | "video"
209+ )
176210 }
177211
178212 fn has_parent_traversal ( & self , path : & str ) -> bool {
@@ -341,6 +375,21 @@ mod tests {
341375 Some ( "../../etc/passwd" . to_string( ) )
342376 ) ;
343377
378+ let call = HostCall :: new (
379+ "fd_read" ,
380+ vec ! [
381+ serde_json:: json!( "text/plain" ) ,
382+ serde_json:: json!( { "path" : "../../etc/passwd" } ) ,
383+ ] ,
384+ ) ;
385+ assert_eq ! (
386+ irm. extract_path( & call) ,
387+ Some ( "../../etc/passwd" . to_string( ) )
388+ ) ;
389+
390+ assert ! ( !irm. looks_like_path( "text/plain" ) ) ;
391+ assert ! ( irm. looks_like_path( "src/main.rs" ) ) ;
392+
344393 let call = HostCall :: new ( "fd_read" , vec ! [ serde_json:: json!( 123 ) ] ) ;
345394 assert_eq ! ( irm. extract_path( & call) , None ) ;
346395 }
@@ -366,6 +415,19 @@ mod tests {
366415 !decision. is_allowed( ) ,
367416 "object traversal path should be denied"
368417 ) ;
418+
419+ let call = HostCall :: new (
420+ "fd_read" ,
421+ vec ! [
422+ serde_json:: json!( "text/plain" ) ,
423+ serde_json:: json!( { "path" : "../../etc/passwd" } ) ,
424+ ] ,
425+ ) ;
426+ let decision = irm. evaluate ( & call, & policy) . await ;
427+ assert ! (
428+ !decision. is_allowed( ) ,
429+ "object traversal path must not be bypassed by slash-containing non-path tokens"
430+ ) ;
369431 }
370432
371433 #[ test]
0 commit comments