@@ -2,37 +2,89 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
22
33use 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