Skip to content

Commit cd5e399

Browse files
fix(provider-utils): align download credentials to strict same-origin
AI SDK's credentialedOrigin sends caller headers only to the exact configured origin; the previous parent-domain sibling rule was wider than that (and unsafe for shared-suffix bases like co.uk or github.io). Credentials now travel only when the target is strictly same-origin with trusted_origin, checked before the first request and on every redirect hop. Also fold the repeated bracketed-IPv6 literal parsing into host_ip_literal.
1 parent 0d2600f commit cd5e399

4 files changed

Lines changed: 26 additions & 222 deletions

File tree

‎aimux-provider-utils/src/download_guard.rs‎

Lines changed: 12 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
7436
fn 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![

‎aimux-provider-utils/src/http.rs‎

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,13 @@ fn is_redirect_status(status: reqwest::StatusCode) -> bool {
14851485
)
14861486
}
14871487

1488+
/// Whether `url` is strictly same-origin with the configured trusted origin.
1489+
/// Credentials and request bodies are confined to this; a `None` trusted
1490+
/// origin trusts nothing.
1491+
fn on_trusted_origin(url: &str, trusted_origin: Option<&str>) -> bool {
1492+
trusted_origin.is_some_and(|origin| crate::download_guard::same_origin(url, origin))
1493+
}
1494+
14881495
fn redirect_error(message: impl Into<String>, status: reqwest::StatusCode) -> AiMuxError {
14891496
AiMuxError::ApiCall(ApiCallError {
14901497
message: message.into(),
@@ -1506,9 +1513,10 @@ async fn send_validated_redirects(
15061513
crate::download_guard::sanitize_download_headers(&mut current.headers);
15071514
// Caller headers may carry credentials (providers poll response-supplied
15081515
// URLs with their API key); a response must not be able to point them at
1509-
// an arbitrary public host. Trust derives from the configured origin, so
1510-
// anything outside its domain gets only a User-Agent.
1511-
if !crate::download_guard::credential_eligible(&current.url, trusted_origin) {
1516+
// any other host. Credentials go only to the configured origin — strictly
1517+
// same-origin, never a guessed parent domain — so anything else gets only
1518+
// a User-Agent.
1519+
if !on_trusted_origin(&current.url, trusted_origin) {
15121520
crate::download_guard::retain_user_agent(&mut current.headers);
15131521
}
15141522
let mut pinned =
@@ -1561,7 +1569,9 @@ async fn send_validated_redirects(
15611569
let next = next.to_string();
15621570
let hop_trusted = crate::download_guard::hop_trusted_origin(trusted_origin, &current.url);
15631571
pinned = crate::download_guard::validate_download_target(&next, hop_trusted).await?;
1564-
if !crate::download_guard::same_origin(&next, &current.url) {
1572+
// Credentials are scoped to the configured origin, not the previous
1573+
// hop: once a redirect leaves it, headers cannot come back.
1574+
if !on_trusted_origin(&next, trusted_origin) {
15651575
crate::download_guard::retain_user_agent(&mut current.headers);
15661576
}
15671577
if status == reqwest::StatusCode::SEE_OTHER
@@ -2340,33 +2350,6 @@ mod tests {
23402350
let _ = result;
23412351
}
23422352

2343-
#[test]
2344-
fn only_fetch_redirect_statuses_are_followed() {
2345-
for status in [301, 302, 303, 307, 308] {
2346-
assert!(is_redirect_status(
2347-
reqwest::StatusCode::from_u16(status).unwrap()
2348-
));
2349-
}
2350-
for status in [300, 304] {
2351-
assert!(!is_redirect_status(
2352-
reqwest::StatusCode::from_u16(status).unwrap()
2353-
));
2354-
}
2355-
}
2356-
2357-
#[test]
2358-
fn pinned_client_rejects_urls_without_a_host() {
2359-
assert!(pinned_client("not a url", &["93.184.216.34".parse().unwrap()]).is_err());
2360-
assert!(
2361-
pinned_client("mailto:x@example.com", &["93.184.216.34".parse().unwrap()]).is_err()
2362-
);
2363-
}
2364-
2365-
#[test]
2366-
fn pinned_client_rejects_an_empty_address_set() {
2367-
assert!(pinned_client("https://example.com/file", &[]).is_err());
2368-
}
2369-
23702353
#[tokio::test]
23712354
async fn pinned_client_resolves_only_through_validated_addresses() {
23722355
use tokio::io::{AsyncReadExt, AsyncWriteExt};

‎aimux-provider-utils/tests/download_guard_test.rs‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,6 @@ async fn rejects_non_public_literal_urls_before_connecting() {
5353
}
5454
}
5555

56-
#[tokio::test]
57-
async fn rejects_localhost_hostnames() {
58-
let error = send_validated_download(
59-
request("http://localhost:9/file".into()),
60-
None,
61-
RetryConfig::default(),
62-
&DEFAULT_ERROR_STRUCTURE,
63-
)
64-
.await
65-
.expect_err("localhost must be rejected");
66-
assert!(
67-
matches!(error, AiMuxError::InvalidArgument(ref m) if m.contains("localhost")),
68-
"unexpected error: {error:?}"
69-
);
70-
}
71-
7256
#[tokio::test]
7357
async fn trusted_origin_download_succeeds_and_keeps_auth_headers() {
7458
let server = MockServer::start().await;

‎aimux-providers/tests/download_ssrf_guard_test.rs‎

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -57,42 +57,6 @@ async fn rejects_response_body_url_pointing_at_loopback_metadata() {
5757
}
5858
}
5959

60-
#[tokio::test]
61-
async fn rejects_response_body_url_pointing_at_loopback_localhost() {
62-
let server = MockServer::start().await;
63-
mock_generations_with_url(&server, "http://localhost:9/x.png").await;
64-
65-
let model = make_model(&server);
66-
let err = model
67-
.do_generate(&options(PROMPT))
68-
.await
69-
.expect_err("guard must block localhost");
70-
match err {
71-
aimux_core::AiMuxError::InvalidArgument(msg) => {
72-
assert!(msg.contains("localhost"), "unexpected message: {msg}");
73-
}
74-
other => panic!("expected InvalidArgument, got {other:?}"),
75-
}
76-
}
77-
78-
#[tokio::test]
79-
async fn rejects_ipv4_mapped_ipv6_loopback() {
80-
let server = MockServer::start().await;
81-
mock_generations_with_url(&server, "http://[::ffff:169.254.169.254]/x.png").await;
82-
83-
let model = make_model(&server);
84-
let err = model
85-
.do_generate(&options(PROMPT))
86-
.await
87-
.expect_err("guard must block ::ffff:-mapped IPv4");
88-
match err {
89-
aimux_core::AiMuxError::InvalidArgument(msg) => {
90-
assert!(msg.contains("non-public"), "unexpected message: {msg}");
91-
}
92-
other => panic!("expected InvalidArgument, got {other:?}"),
93-
}
94-
}
95-
9660
#[tokio::test]
9761
async fn allows_same_origin_download_against_configured_base_url() {
9862
// A self-hosted deployment legitimately returns URLs on its own host.
@@ -115,23 +79,3 @@ async fn allows_same_origin_download_against_configured_base_url() {
11579
other => panic!("expected Binary outputs, got {other:?}"),
11680
}
11781
}
118-
119-
#[tokio::test]
120-
async fn rejects_cross_origin_private_ip_even_with_trusted_origin_set() {
121-
// trustedOrigin only exempts the configured origin; other origins that
122-
// resolve to private space are still blocked.
123-
let server = MockServer::start().await;
124-
mock_generations_with_url(&server, "http://10.1.2.3/x.png").await;
125-
126-
let model = make_model(&server);
127-
let err = model
128-
.do_generate(&options(PROMPT))
129-
.await
130-
.expect_err("guard must block private IPv4 on a foreign origin");
131-
match err {
132-
aimux_core::AiMuxError::InvalidArgument(msg) => {
133-
assert!(msg.contains("non-public"), "unexpected message: {msg}");
134-
}
135-
other => panic!("expected InvalidArgument, got {other:?}"),
136-
}
137-
}

0 commit comments

Comments
 (0)