@@ -33,50 +33,22 @@ pub(crate) fn hop_trusted_origin<'a>(
3333 trusted_origin. filter ( |origin| same_origin ( current_url, origin) )
3434}
3535
36- /// Whether caller headers (which may carry credentials) may be sent to `url`:
37- /// same origin as the trusted origin, or a same-scheme sibling host under the
38- /// trusted origin's parent domain — providers routinely serve polling URLs
39- /// from regional hosts (api.us1.bfl.ai for a base of api.bfl.ai). The parent
40- /// must keep at least two labels so an apex base never widens the trust to a
41- /// public suffix, and trust always derives from the configured origin, never
42- /// from the response.
43- pub ( crate ) fn credential_eligible ( url : & str , trusted_origin : Option < & str > ) -> bool {
44- let Some ( origin) = trusted_origin else {
45- return false ;
46- } ;
47- if same_origin ( url, origin) {
48- return true ;
49- }
50- let ( Ok ( url) , Ok ( origin) ) = ( url:: Url :: parse ( url) , url:: Url :: parse ( origin) ) else {
51- return false ;
52- } ;
53- if url. scheme ( ) != origin. scheme ( ) {
54- return false ;
55- }
56- let ( Some ( host) , Some ( origin_host) ) = ( url. host_str ( ) , origin. host_str ( ) ) else {
57- return false ;
58- } ;
59- // IP-literal origins have no domain family; only an exact origin match
60- // (handled above) qualifies. Otherwise "127.0.0.1" would parse a bogus
61- // "0.0.1" parent and treat every 127.* port as a sibling.
62- if origin_host. parse :: < IpAddr > ( ) . is_ok ( ) {
63- return false ;
64- }
65- let Some ( ( _, parent) ) = origin_host. split_once ( '.' ) else {
66- return false ;
67- } ;
68- parent. contains ( '.' )
69- && host. len ( ) > parent. len ( )
70- && host. ends_with ( parent)
71- && host. as_bytes ( ) [ host. len ( ) - parent. len ( ) - 1 ] == b'.'
72- }
73-
7436fn without_query ( parsed : & url:: Url ) -> String {
7537 let mut redacted = parsed. clone ( ) ;
7638 redacted. set_query ( None ) ;
7739 redacted. to_string ( )
7840}
7941
42+ /// Parse a host as `url::Url::host_str` yields it into an IP literal. The url
43+ /// crate keeps IPv6 hosts bracketed (and may render mapped addresses in hex
44+ /// form, e.g. "[::ffff:7f00:1]"), so brackets are stripped before parsing.
45+ fn host_ip_literal ( host : & str ) -> Option < IpAddr > {
46+ host. trim_start_matches ( '[' )
47+ . trim_end_matches ( ']' )
48+ . parse ( )
49+ . ok ( )
50+ }
51+
8052/// Syntactic checks: scheme, disallowed hostnames, and literal-IP publicness.
8153///
8254/// Callers that resolve DNS afterwards rely on this having rejected every
@@ -103,13 +75,7 @@ pub(crate) fn validate_download_url(url: &str) -> Result<url::Url, AiMuxError> {
10375 "download URL targets a disallowed hostname: {normalized_host}"
10476 ) ) ) ;
10577 }
106- // The url crate keeps IPv6 hosts bracketed (and may render mapped
107- // addresses in hex form, e.g. "[::ffff:7f00:1]"); strip brackets before
108- // parsing.
109- let bare_host = normalized_host
110- . trim_start_matches ( '[' )
111- . trim_end_matches ( ']' ) ;
112- if let Ok ( address) = bare_host. parse :: < IpAddr > ( )
78+ if let Some ( address) = host_ip_literal ( normalized_host)
11379 && !is_public_download_address ( address)
11480 {
11581 return Err ( AiMuxError :: InvalidArgument ( format ! (
@@ -136,8 +102,7 @@ pub(crate) async fn validate_download_target(
136102 let host = parsed
137103 . host_str ( )
138104 . ok_or_else ( || AiMuxError :: InvalidArgument ( "download URL has no host" . into ( ) ) ) ?;
139- let bare_host = host. trim_start_matches ( '[' ) . trim_end_matches ( ']' ) ;
140- if let Ok ( address) = bare_host. parse :: < IpAddr > ( ) {
105+ if let Some ( address) = host_ip_literal ( host) {
141106 // validate_download_url above already rejected non-public literals.
142107 return Ok ( vec ! [ address] ) ;
143108 }
@@ -417,22 +382,6 @@ mod tests {
417382 ) ;
418383 }
419384
420- #[ tokio:: test]
421- async fn validate_download_target_rejects_mapped_literal_hosts ( ) {
422- for url in [
423- "http://[::ffff:127.0.0.1]:9/x" ,
424- "http://[::ffff:169.254.169.254]/meta" ,
425- ] {
426- let error = validate_download_target ( url, None )
427- . await
428- . expect_err ( "mapped private literal must be rejected" ) ;
429- assert ! (
430- matches!( error, AiMuxError :: InvalidArgument ( ref m) if m. contains( "non-public" ) ) ,
431- "unexpected error for {url}: {error:?}"
432- ) ;
433- }
434- }
435-
436385 #[ tokio:: test]
437386 async fn trusted_origins_skip_target_resolution ( ) {
438387 let addresses = validate_download_target (
@@ -444,62 +393,6 @@ mod tests {
444393 assert ! ( addresses. is_empty( ) ) ;
445394 }
446395
447- #[ test]
448- fn credentials_stay_within_the_trusted_origin_family ( ) {
449- let base = Some ( "https://api.bfl.ai" ) ;
450- // Same origin and same-parent-domain siblings are eligible.
451- assert ! ( credential_eligible(
452- "https://api.bfl.ai/v1/get_result" ,
453- base
454- ) ) ;
455- assert ! ( credential_eligible(
456- "https://api.us1.bfl.ai/v1/get_result" ,
457- base
458- ) ) ;
459- // Foreign hosts, lookalike suffixes, scheme downgrades, and
460- // response-chosen public hosts are not.
461- assert ! ( !credential_eligible( "https://attacker.example/poll" , base) ) ;
462- assert ! ( !credential_eligible( "https://evil-bfl.ai/poll" , base) ) ;
463- assert ! ( !credential_eligible( "http://api.us1.bfl.ai/poll" , base) ) ;
464- assert ! ( !credential_eligible( "https://api.bfl.ai/x" , None ) ) ;
465- // An apex base must not widen trust to the entire public suffix.
466- assert ! ( !credential_eligible(
467- "https://other.ai/poll" ,
468- Some ( "https://bfl.ai" )
469- ) ) ;
470- // A deeper base widens only to its own organization's domain.
471- assert ! ( credential_eligible(
472- "https://cdn.example.co.uk/file" ,
473- Some ( "https://api.example.co.uk" )
474- ) ) ;
475- assert ! ( !credential_eligible(
476- "https://evil.co.uk/file" ,
477- Some ( "https://api.example.co.uk" )
478- ) ) ;
479- // An IP-literal origin has no domain family: only the exact origin.
480- assert ! ( credential_eligible(
481- "http://127.0.0.1:8080/file" ,
482- Some ( "http://127.0.0.1:8080" )
483- ) ) ;
484- assert ! ( !credential_eligible(
485- "http://127.0.0.1:9090/file" ,
486- Some ( "http://127.0.0.1:8080" )
487- ) ) ;
488- }
489-
490- #[ test]
491- fn trusted_origin_does_not_extend_to_foreign_hops ( ) {
492- let trusted = Some ( "http://localhost:43123" ) ;
493- assert_eq ! (
494- hop_trusted_origin( trusted, "http://localhost:43123/step" ) ,
495- Some ( "http://localhost:43123" )
496- ) ;
497- assert_eq ! (
498- hop_trusted_origin( trusted, "https://evil.example/step" ) ,
499- None
500- ) ;
501- }
502-
503396 #[ test]
504397 fn download_header_policy_matches_ai_sdk ( ) {
505398 let mut headers = vec ! [
0 commit comments