Skip to content

Commit 4e25954

Browse files
rubenfiszelclaude
andauthored
fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs (#9188)
* fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: impl std::error::Error for SsrfValidationError for anyhow callers Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 81b5736 commit 4e25954

2 files changed

Lines changed: 108 additions & 23 deletions

File tree

backend/windmill-ai/src/ai_providers.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
* This file contains shared AI provider utilities used by both the API and worker.
33
*/
44

5+
use serde::{Deserialize, Deserializer, Serialize};
56
use windmill_common::db::DB;
67
use windmill_common::error::{Error, Result};
7-
use serde::{Deserialize, Deserializer, Serialize};
88

99
/// Deserializes an Option<String> where empty strings become None.
1010
/// Use with `#[serde(default, deserialize_with = "empty_string_as_none")]`
@@ -65,13 +65,19 @@ impl AIProvider {
6565
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
6666
if let Some(base_url) = resource_base_url {
6767
if !*ALLOW_PRIVATE_AI_BASE_URLS {
68+
use windmill_common::ssrf::SsrfValidationError;
6869
windmill_common::ssrf::validate_url_for_ssrf(&base_url)
6970
.await
70-
.map_err(|e| {
71-
Error::BadRequest(format!(
71+
.map_err(|e| match e {
72+
// The env-var hint is only actionable when the URL is
73+
// well-formed but blocked for targeting a private
74+
// address. For a malformed URL or bad scheme, surface
75+
// the real error so users fix the URL (issue #9171).
76+
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
7277
"{e}. If you need to use private/internal AI endpoints, \
73-
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
74-
))
78+
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
79+
)),
80+
e => Error::from(e),
7581
})?;
7682
}
7783
return Ok(base_url);

backend/windmill-common/src/ssrf.rs

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,89 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
22

33
use crate::error::Error;
44

5+
/// Why a URL failed SSRF validation.
6+
///
7+
/// The distinction matters for callers that gate private endpoints behind a
8+
/// flag (e.g. `ALLOW_PRIVATE_AI_BASE_URLS`): a "set this env var" hint is only
9+
/// actionable for [`SsrfValidationError::Private`]. Surfacing that hint for a
10+
/// malformed URL or bad scheme sends users down the wrong path (see #9171).
11+
#[derive(Debug)]
12+
pub enum SsrfValidationError {
13+
/// The URL could not be parsed (e.g. missing `http://` scheme).
14+
InvalidUrl(String),
15+
/// Scheme is not `http`/`https`.
16+
DisallowedScheme(String),
17+
/// No host in the URL.
18+
MissingHost,
19+
/// DNS resolution failed for the host.
20+
ResolutionFailed { host: String, source: String },
21+
/// Host did not resolve to any address.
22+
NoAddresses(String),
23+
/// The URL targets (or resolves to) a private/internal address. `resolved`
24+
/// is true when the host was a DNS name that resolved to a private IP.
25+
Private { resolved: bool },
26+
}
27+
28+
impl std::fmt::Display for SsrfValidationError {
29+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30+
match self {
31+
SsrfValidationError::InvalidUrl(e) => write!(f, "Invalid URL: {e}"),
32+
SsrfValidationError::DisallowedScheme(s) => write!(
33+
f,
34+
"URL scheme '{s}' is not allowed, only http and https are permitted"
35+
),
36+
SsrfValidationError::MissingHost => write!(f, "URL must have a host"),
37+
SsrfValidationError::ResolutionFailed { host, source } => {
38+
write!(f, "Failed to resolve host '{host}': {source}")
39+
}
40+
SsrfValidationError::NoAddresses(host) => {
41+
write!(f, "Host '{host}' did not resolve to any addresses")
42+
}
43+
SsrfValidationError::Private { resolved: false } => {
44+
write!(f, "URL targets a private/internal IP address")
45+
}
46+
SsrfValidationError::Private { resolved: true } => {
47+
write!(f, "URL resolves to a private/internal IP address")
48+
}
49+
}
50+
}
51+
}
52+
53+
// Enables `?` from `validate_url_for_ssrf` in functions returning
54+
// `anyhow::Result` (e.g. the EE SAML metadata loader).
55+
impl std::error::Error for SsrfValidationError {}
56+
57+
impl From<SsrfValidationError> for Error {
58+
fn from(e: SsrfValidationError) -> Self {
59+
Error::BadRequest(e.to_string())
60+
}
61+
}
62+
563
/// Validates that a URL is safe to fetch server-side (not targeting private/internal networks).
664
///
765
/// Checks:
866
/// 1. Scheme must be http or https
967
/// 2. Host must be present and not a private/loopback/link-local IP
1068
/// 3. DNS resolution is checked to prevent DNS rebinding to internal IPs
11-
pub async fn validate_url_for_ssrf(url: &str) -> Result<(), Error> {
69+
pub async fn validate_url_for_ssrf(url: &str) -> Result<(), SsrfValidationError> {
1270
let parsed =
13-
url::Url::parse(url).map_err(|e| Error::BadRequest(format!("Invalid URL: {e}")))?;
71+
url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?;
1472

1573
// 1. Scheme check
1674
match parsed.scheme() {
1775
"http" | "https" => {}
1876
scheme => {
19-
return Err(Error::BadRequest(format!(
20-
"URL scheme '{scheme}' is not allowed, only http and https are permitted"
21-
)));
77+
return Err(SsrfValidationError::DisallowedScheme(scheme.to_string()));
2278
}
2379
}
2480

2581
// 2. Host check
26-
let host = parsed
27-
.host_str()
28-
.ok_or_else(|| Error::BadRequest("URL must have a host".to_string()))?;
82+
let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?;
2983

3084
// 3. If the host is an IP literal, check it directly
3185
if let Ok(ip) = host.parse::<IpAddr>() {
3286
if is_private_ip(&ip) {
33-
return Err(Error::BadRequest(
34-
"URL targets a private/internal IP address".to_string(),
35-
));
87+
return Err(SsrfValidationError::Private { resolved: false });
3688
}
3789
return Ok(());
3890
}
@@ -45,20 +97,19 @@ pub async fn validate_url_for_ssrf(url: &str) -> Result<(), Error> {
4597
let resolve_target = format!("{host}:{port}");
4698
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&resolve_target)
4799
.await
48-
.map_err(|e| Error::BadRequest(format!("Failed to resolve host '{host}': {e}")))?
100+
.map_err(|e| SsrfValidationError::ResolutionFailed {
101+
host: host.to_string(),
102+
source: e.to_string(),
103+
})?
49104
.collect();
50105

51106
if addrs.is_empty() {
52-
return Err(Error::BadRequest(format!(
53-
"Host '{host}' did not resolve to any addresses"
54-
)));
107+
return Err(SsrfValidationError::NoAddresses(host.to_string()));
55108
}
56109

57110
for addr in &addrs {
58111
if is_private_ip(&addr.ip()) {
59-
return Err(Error::BadRequest(
60-
"URL resolves to a private/internal IP address".to_string(),
61-
));
112+
return Err(SsrfValidationError::Private { resolved: true });
62113
}
63114
}
64115

@@ -148,4 +199,32 @@ mod tests {
148199
// This resolves to a public IP
149200
assert!(validate_url_for_ssrf("https://google.com").await.is_ok());
150201
}
202+
203+
/// Regression for #9171: a malformed base URL (missing scheme) must report
204+
/// `InvalidUrl`/`DisallowedScheme`, not `Private` — only `Private` gets the
205+
/// "set ALLOW_PRIVATE_AI_BASE_URLS" hint, which is misleading for a typo'd
206+
/// URL and sent the issue reporter down the wrong path.
207+
#[tokio::test]
208+
async fn test_error_variants_are_discriminated() {
209+
// No scheme and no colon → the exact "relative URL without a base"
210+
// error from the issue.
211+
assert!(matches!(
212+
validate_url_for_ssrf("api.example.com/v1").await,
213+
Err(SsrfValidationError::InvalidUrl(_))
214+
));
215+
// `localhost:11434/v1` parses with `localhost` as the scheme — a very
216+
// common Ollama misconfiguration.
217+
assert!(matches!(
218+
validate_url_for_ssrf("localhost:11434/v1").await,
219+
Err(SsrfValidationError::DisallowedScheme(s)) if s == "localhost"
220+
));
221+
assert!(matches!(
222+
validate_url_for_ssrf("ftp://example.com/foo").await,
223+
Err(SsrfValidationError::DisallowedScheme(_))
224+
));
225+
assert!(matches!(
226+
validate_url_for_ssrf("http://127.0.0.1/foo").await,
227+
Err(SsrfValidationError::Private { resolved: false })
228+
));
229+
}
151230
}

0 commit comments

Comments
 (0)