Skip to main content

av_harness/
backend.rs

1//! Per-backend credential routing and network isolation (pillar 4).
2//!
3//! Each backend has a name, URL, authentication policy, and a set of tools
4//! it serves. The router resolves a tool name to its backend and constructs
5//! the appropriate credential header. Backend URLs come only from config;
6//! redirects are not followed.
7
8use crate::config::{BackendAuth, BackendConfig, BackendTransport};
9use axum::http::{HeaderName, HeaderValue};
10use std::collections::HashMap;
11
12/// Name of the implicit backend built from `tool_upstream_url`. It is
13/// also the audience of intent tokens sent to that upstream.
14pub const DEFAULT_BACKEND_NAME: &str = "default";
15
16/// Validate an HTTP endpoint before constructing a network request.
17pub(crate) fn validate_http_endpoint(url: &str, field: &str) -> Result<(), String> {
18    let authority = url
19        .split_once("://")
20        .map(|(_, rest)| rest.split(['/', '?', '#']).next().unwrap_or_default());
21    if url.trim() != url || authority.is_none_or(str::is_empty) {
22        return Err(format!(
23            "{field} must be a non-empty http:// or https:// URL with a host"
24        ));
25    }
26    let parsed = reqwest::Url::parse(url).map_err(|_| format!("{field} is not a valid URL"))?;
27    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none_or(str::is_empty) {
28        return Err(format!("{field} must be an http:// or https:// URL with a host"));
29    }
30    if !parsed.username().is_empty() || parsed.password().is_some() {
31        return Err(format!(
32            "{field} must not contain credentials; configure authentication separately"
33        ));
34    }
35    Ok(())
36}
37
38/// True for an address that is not reachable from the public internet:
39/// loopback, RFC 1918, shared (CGNAT, RFC 6598), link-local, and IPv6
40/// unique-local or link-local addresses (including IPv4-mapped forms).
41pub fn is_private_ip(ip: std::net::IpAddr) -> bool {
42    match ip {
43        std::net::IpAddr::V4(v4) => {
44            let [first, second, ..] = v4.octets();
45            v4.is_loopback()
46                || v4.is_private()
47                || v4.is_link_local()
48                || (first == 100 && (second & 0xc0) == 64)
49        }
50        std::net::IpAddr::V6(v6) => {
51            let [first, ..] = v6.segments();
52            v6.is_loopback()
53                || (first & 0xfe00) == 0xfc00
54                || (first & 0xffc0) == 0xfe80
55                || v6
56                    .to_ipv4_mapped()
57                    .is_some_and(|v4| is_private_ip(std::net::IpAddr::V4(v4)))
58        }
59    }
60}
61
62/// DNS resolver for the backend client under `require_private_backends`:
63/// a backend host must resolve only to private addresses, checked on
64/// every connection (so a DNS change after boot cannot redirect tool
65/// traffic to the internet). A host with any public address is refused
66/// outright rather than filtered, because mixed answers indicate a
67/// misconfiguration or a rebinding attempt.
68#[derive(Debug, Clone, Copy, Default)]
69pub struct PrivateOnlyResolver;
70
71impl reqwest::dns::Resolve for PrivateOnlyResolver {
72    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
73        let host = name.as_str().to_owned();
74        Box::pin(async move {
75            let addresses: Vec<std::net::SocketAddr> =
76                tokio::net::lookup_host((host.as_str(), 0)).await?.collect();
77            if addresses.is_empty() {
78                return Err(format!("backend host {host} has no addresses").into());
79            }
80            if let Some(public) = addresses.iter().find(|address| !is_private_ip(address.ip())) {
81                tracing::warn!(%host, address = %public.ip(), "refused a backend host that resolves to a public address");
82                return Err(format!(
83                    "backend host {host} resolves to non-private address {}",
84                    public.ip()
85                )
86                .into());
87            }
88            Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs)
89        })
90    }
91}
92
93/// Under `require_private_backends`, an IP literal in a backend URL must be
94/// private (the resolver is not consulted for literals).
95pub(crate) fn validate_private_endpoint(url: &str, field: &str) -> Result<(), String> {
96    let parsed = reqwest::Url::parse(url).map_err(|_| format!("{field} is not a valid URL"))?;
97    let host = parsed.host_str().unwrap_or_default();
98    let Ok(ip) = host
99        .trim_start_matches('[')
100        .trim_end_matches(']')
101        .parse::<std::net::IpAddr>()
102    else {
103        return Ok(());
104    };
105    if is_private_ip(ip) {
106        Ok(())
107    } else {
108        Err(format!(
109            "{field} uses the public address {ip}, but require_private_backends only allows private backend addresses"
110        ))
111    }
112}
113
114/// Validate routing structure without reading credentials or contacting services.
115pub(crate) fn validate_configs(configs: &[BackendConfig]) -> Vec<String> {
116    let mut errors = Vec::new();
117    let mut names = std::collections::HashSet::new();
118    let mut tools = std::collections::HashSet::new();
119    let mut default_seen = false;
120    for backend in configs {
121        if backend.name.trim().is_empty() || backend.name.chars().any(|c| c.is_whitespace() || c.is_control())
122        {
123            errors.push("backend name must not be empty or contain whitespace/control characters".into());
124        }
125        if backend.name == DEFAULT_BACKEND_NAME {
126            errors.push("backend name \"default\" is reserved for tool_upstream_url".into());
127        }
128        if !names.insert(&backend.name) {
129            errors.push(format!("duplicate backend name {:?}", backend.name));
130        }
131        if let Err(error) = validate_http_endpoint(&backend.url, &format!("backend {:?} url", backend.name)) {
132            errors.push(error);
133        }
134        if backend.tools.is_empty() {
135            if default_seen {
136                errors.push("multiple backends with empty tools list (only one default allowed)".into());
137            }
138            default_seen = true;
139        }
140        for tool in &backend.tools {
141            if tool.is_empty()
142                || !tool.is_ascii()
143                || tool
144                    .bytes()
145                    .any(|b| b.is_ascii_uppercase() || b.is_ascii_whitespace() || b.is_ascii_control())
146            {
147                errors.push(format!("backend {:?}: tool {tool:?} must be non-empty lowercase ASCII without whitespace or controls", backend.name));
148            }
149            if !tools.insert(tool) {
150                errors.push(format!(
151                    "tool {tool:?} mapped to multiple backends or repeated in backend {:?}",
152                    backend.name
153                ));
154            }
155        }
156        match &backend.auth {
157            BackendAuth::StaticEnv(value) | BackendAuth::StaticFile(value) if value.trim().is_empty() => {
158                errors.push(format!(
159                    "backend {:?}: authentication source must not be empty",
160                    backend.name
161                ));
162            }
163            _ => {}
164        }
165    }
166    errors
167}
168
169/// Resolved backend with its credential.
170#[derive(Debug, Clone)]
171pub struct ResolvedBackend {
172    /// Backend name (for metrics and logging).
173    pub name: String,
174    /// Base URL.
175    pub url: String,
176    /// Pre-resolved auth header (name + value), if any.
177    pub auth_header: Option<(HeaderName, HeaderValue)>,
178    /// Auth mode (for token exchange decisions).
179    pub auth_mode: BackendAuthMode,
180    /// Wire protocol spoken to this backend.
181    pub transport: BackendTransport,
182    /// Tools explicitly routed here. Empty for the default backend.
183    pub tools: Vec<String>,
184}
185
186/// Simplified auth mode for runtime decisions.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum BackendAuthMode {
189    /// No credentials attached.
190    None,
191    /// Static credential (env var or file).
192    Static,
193    /// Credential obtained via RFC 8693 token exchange.
194    Exchange,
195    /// The caller's own validated bearer token, forwarded to this backend only.
196    ForwardToken,
197}
198
199/// Routes tool names to backends. Built once at startup from config.
200pub struct BackendRouter {
201    tool_to_backend: HashMap<String, usize>,
202    backends: Vec<ResolvedBackend>,
203    default_idx: Option<usize>,
204}
205
206impl BackendRouter {
207    /// Build from config. Resolves static credentials at construction time
208    /// so the hot path does no I/O. `fallback_auth` is the credential for
209    /// `fallback_url` (the `tool_upstream_bearer_*` setting); it rides on
210    /// the implicit default backend so every call to that upstream carries it.
211    pub fn new(
212        configs: &[BackendConfig],
213        fallback_url: Option<&str>,
214        fallback_auth: Option<HeaderValue>,
215    ) -> Result<Self, String> {
216        let errors = validate_configs(configs);
217        if !errors.is_empty() {
218            return Err(errors.join("; "));
219        }
220        if let Some(url) = fallback_url {
221            validate_http_endpoint(url, "tool_upstream_url")?;
222        }
223        let mut backends = Vec::new();
224        let mut tool_to_backend = HashMap::new();
225        let mut default_idx = None;
226
227        for (idx, cfg) in configs.iter().enumerate() {
228            let what = format!("backend {:?} bearer token", cfg.name);
229            let secret = match &cfg.auth {
230                BackendAuth::None | BackendAuth::Exchange | BackendAuth::ForwardToken => None,
231                BackendAuth::StaticEnv(var) => {
232                    crate::pipeline::read_secret(Some(var), None, &what).map_err(|e| e.to_string())?
233                }
234                BackendAuth::StaticFile(path) => {
235                    crate::pipeline::read_secret(None, Some(path), &what).map_err(|e| e.to_string())?
236                }
237            };
238            let auth_header = match secret {
239                Some(token) => {
240                    let mut value = HeaderValue::from_str(&format!("Bearer {token}"))
241                        .map_err(|_| format!("{what} contains bytes that cannot appear in an HTTP header"))?;
242                    value.set_sensitive(true);
243                    Some((HeaderName::from_static("authorization"), value))
244                }
245                None => None,
246            };
247            let auth_mode = match &cfg.auth {
248                BackendAuth::None => BackendAuthMode::None,
249                BackendAuth::StaticEnv(_) | BackendAuth::StaticFile(_) => BackendAuthMode::Static,
250                BackendAuth::Exchange => BackendAuthMode::Exchange,
251                BackendAuth::ForwardToken => BackendAuthMode::ForwardToken,
252            };
253            backends.push(ResolvedBackend {
254                name: cfg.name.clone(),
255                url: cfg.url.clone(),
256                auth_header,
257                auth_mode,
258                transport: cfg.transport,
259                tools: cfg.tools.clone(),
260            });
261            if cfg.tools.is_empty() {
262                if default_idx.is_some() {
263                    return Err(format!(
264                        "backend {:?}: multiple backends with empty tools list (only one default allowed)",
265                        cfg.name
266                    ));
267                }
268                default_idx = Some(idx);
269            }
270            for tool in &cfg.tools {
271                if tool_to_backend.contains_key(tool) {
272                    return Err(format!(
273                        "tool {tool:?} mapped to multiple backends (backend {:?} conflicts)",
274                        cfg.name
275                    ));
276                }
277                tool_to_backend.insert(tool.clone(), idx);
278            }
279        }
280
281        // If no backends are configured, create an implicit default from
282        // the fallback URL (backward compatibility with tool_upstream_url).
283        if backends.is_empty() {
284            if let Some(url) = fallback_url {
285                let auth_mode = if fallback_auth.is_some() {
286                    BackendAuthMode::Static
287                } else {
288                    BackendAuthMode::None
289                };
290                backends.push(ResolvedBackend {
291                    name: DEFAULT_BACKEND_NAME.into(),
292                    url: url.to_owned(),
293                    auth_header: fallback_auth.map(|value| (HeaderName::from_static("authorization"), value)),
294                    auth_mode,
295                    transport: BackendTransport::JsonRpc,
296                    tools: Vec::new(),
297                });
298                default_idx = Some(0);
299            }
300        }
301
302        Ok(Self {
303            tool_to_backend,
304            backends,
305            default_idx,
306        })
307    }
308
309    /// Resolve a tool name to its backend. Returns `None` when no backend
310    /// is configured for this tool and no default exists.
311    pub fn resolve(&self, tool: &str) -> Option<&ResolvedBackend> {
312        let idx = self.tool_to_backend.get(tool).copied().or(self.default_idx)?;
313        self.backends.get(idx)
314    }
315
316    /// Number of configured backends.
317    pub fn len(&self) -> usize {
318        self.backends.len()
319    }
320
321    /// True when no backends are configured.
322    pub fn is_empty(&self) -> bool {
323        self.backends.is_empty()
324    }
325
326    /// Iterator over all configured backends.
327    pub fn iter(&self) -> impl Iterator<Item = &ResolvedBackend> {
328        self.backends.iter()
329    }
330
331    /// The backend with this name, if configured.
332    pub fn get(&self, name: &str) -> Option<&ResolvedBackend> {
333        self.backends.iter().find(|backend| backend.name == name)
334    }
335
336    /// Set the wire protocol of the implicit backend built from
337    /// `tool_upstream_url` (`tool_upstream_transport`). Explicit
338    /// `[[backends]]` entries keep their own setting.
339    #[must_use]
340    pub fn with_fallback_transport(mut self, transport: BackendTransport) -> Self {
341        for backend in &mut self.backends {
342            if backend.name == DEFAULT_BACKEND_NAME {
343                backend.transport = transport;
344            }
345        }
346        self
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
353
354    use super::*;
355
356    fn backend(name: &str, url: &str, tools: &[&str]) -> BackendConfig {
357        BackendConfig {
358            name: name.into(),
359            url: url.into(),
360            auth: BackendAuth::None,
361            tools: tools.iter().map(|s| (*s).to_owned()).collect(),
362            transport: BackendTransport::default(),
363        }
364    }
365
366    #[test]
367    fn private_address_classification() {
368        for private in [
369            "127.0.0.1",
370            "10.1.2.3",
371            "172.16.0.1",
372            "192.168.1.1",
373            "169.254.1.1",
374            "100.64.0.1",
375            "::1",
376            "fd00::1",
377            "fe80::1",
378            "::ffff:10.0.0.1",
379        ] {
380            assert!(is_private_ip(private.parse().unwrap()), "{private} is private");
381        }
382        for public in [
383            "8.8.8.8",
384            "100.128.0.1",
385            "172.32.0.1",
386            "2001:4860::8888",
387            "::ffff:8.8.8.8",
388            "0.0.0.0",
389        ] {
390            assert!(!is_private_ip(public.parse().unwrap()), "{public} is public");
391        }
392    }
393
394    #[test]
395    fn private_endpoint_literals() {
396        assert!(validate_private_endpoint("http://10.0.0.5:8080/mcp", "f").is_ok());
397        assert!(validate_private_endpoint("http://[fd00::5]:8080/mcp", "f").is_ok());
398        assert!(validate_private_endpoint("http://backend.internal:8080/mcp", "f").is_ok());
399        assert!(validate_private_endpoint("http://8.8.8.8/mcp", "f").is_err());
400    }
401
402    #[tokio::test]
403    async fn private_only_resolver_allows_localhost_only() {
404        use reqwest::dns::Resolve as _;
405        let resolved: Vec<std::net::SocketAddr> = PrivateOnlyResolver
406            .resolve("localhost".parse().unwrap())
407            .await
408            .unwrap()
409            .collect();
410        assert!(resolved.iter().all(|address| address.ip().is_loopback()));
411    }
412
413    #[test]
414    fn explicit_tool_routing() {
415        let router = BackendRouter::new(
416            &[
417                backend("db", "http://db:8080", &["db_write", "db_read"]),
418                backend("search", "http://search:8080", &["search"]),
419            ],
420            None,
421            None,
422        )
423        .unwrap();
424        assert_eq!(router.resolve("db_write").unwrap().name, "db");
425        assert_eq!(router.resolve("search").unwrap().name, "search");
426        assert!(router.resolve("unknown").is_none());
427    }
428
429    #[test]
430    fn router_rejects_invalid_names_urls_and_duplicate_audiences() {
431        for config in [
432            backend("", "http://db", &["read"]),
433            backend("default", "http://db", &["read"]),
434            backend("db", "https:///mcp", &["read"]),
435            backend("db", "file:///mcp", &["read"]),
436            backend("db", "http://db:invalid", &["read"]),
437        ] {
438            assert!(BackendRouter::new(&[config], None, None).is_err());
439        }
440        assert!(BackendRouter::new(
441            &[
442                backend("db", "http://db", &["read"]),
443                backend("db", "http://other", &["write"]),
444            ],
445            None,
446            None
447        )
448        .err()
449        .unwrap()
450        .contains("duplicate backend name"));
451        assert!(BackendRouter::new(&[], Some("http:///mcp"), None).is_err());
452    }
453
454    #[test]
455    fn default_backend_catches_unmapped_tools() {
456        let router = BackendRouter::new(
457            &[
458                backend("db", "http://db:8080", &["db_write"]),
459                backend("fallback", "http://fallback:8080", &[]),
460            ],
461            None,
462            None,
463        )
464        .unwrap();
465        assert_eq!(router.resolve("db_write").unwrap().name, "db");
466        assert_eq!(router.resolve("anything").unwrap().name, "fallback");
467    }
468
469    #[test]
470    fn implicit_default_from_fallback_url() {
471        let router = BackendRouter::new(&[], Some("http://tool:8080"), None).unwrap();
472        assert_eq!(router.resolve("any_tool").unwrap().name, "default");
473        assert_eq!(router.resolve("any_tool").unwrap().url, "http://tool:8080");
474    }
475
476    #[test]
477    fn duplicate_tool_mapping_refused() {
478        let result = BackendRouter::new(
479            &[
480                backend("a", "http://a:8080", &["shared_tool"]),
481                backend("b", "http://b:8080", &["shared_tool"]),
482            ],
483            None,
484            None,
485        );
486        assert!(result.is_err());
487    }
488
489    #[test]
490    fn multiple_default_backends_refused() {
491        let result = BackendRouter::new(
492            &[
493                backend("a", "http://a:8080", &[]),
494                backend("b", "http://b:8080", &[]),
495            ],
496            None,
497            None,
498        );
499        assert!(result.is_err());
500    }
501
502    #[test]
503    fn no_backends_and_no_fallback() {
504        let router = BackendRouter::new(&[], None, None).unwrap();
505        assert!(router.is_empty());
506        assert!(router.resolve("anything").is_none());
507    }
508
509    #[test]
510    fn credential_isolation_between_backends() {
511        std::env::set_var("TEST_BACKEND_A_KEY", "secret-a");
512        let router = BackendRouter::new(
513            &[
514                BackendConfig {
515                    name: "a".into(),
516                    url: "http://a:8080".into(),
517                    auth: BackendAuth::StaticEnv("TEST_BACKEND_A_KEY".into()),
518                    tools: vec!["tool_a".into()],
519                    transport: Default::default(),
520                },
521                backend("b", "http://b:8080", &["tool_b"]),
522            ],
523            None,
524            None,
525        )
526        .unwrap();
527        let a = router.resolve("tool_a").unwrap();
528        assert!(a.auth_header.is_some());
529        let b = router.resolve("tool_b").unwrap();
530        assert!(b.auth_header.is_none());
531        std::env::remove_var("TEST_BACKEND_A_KEY");
532    }
533
534    fn static_env_backend(name: &str, var: &str) -> BackendConfig {
535        BackendConfig {
536            name: name.into(),
537            url: format!("http://{name}:8080"),
538            auth: BackendAuth::StaticEnv(var.into()),
539            tools: vec![format!("{name}_tool")],
540            transport: Default::default(),
541        }
542    }
543
544    #[test]
545    fn implicit_default_carries_the_tool_upstream_bearer() {
546        let bearer = HeaderValue::from_static("Bearer tool-secret");
547        let router = BackendRouter::new(&[], Some("http://tool:8080/mcp"), Some(bearer)).unwrap();
548        let default = router.resolve("anything").unwrap();
549        assert_eq!(default.name, DEFAULT_BACKEND_NAME);
550        assert_eq!(default.auth_mode, BackendAuthMode::Static);
551        let (name, value) = default
552            .auth_header
553            .as_ref()
554            .expect("tool bearer must ride on the default");
555        assert_eq!(name.as_str(), "authorization");
556        assert_eq!(value.to_str().unwrap(), "Bearer tool-secret");
557    }
558
559    #[test]
560    fn explicit_backends_never_inherit_the_tool_upstream_bearer() {
561        let bearer = HeaderValue::from_static("Bearer tool-secret");
562        let router = BackendRouter::new(
563            &[backend("plain", "http://plain:8080", &["plain_tool"])],
564            Some("http://tool:8080/mcp"),
565            Some(bearer),
566        )
567        .unwrap();
568        assert!(router.resolve("plain_tool").unwrap().auth_header.is_none());
569        assert!(
570            router.resolve("unmapped").is_none(),
571            "with explicit backends, unmapped tools fall back to tool_upstream_url outside the router"
572        );
573    }
574
575    #[test]
576    fn static_credentials_are_sensitive_and_trimmed() {
577        std::env::set_var("AV_TEST_BACKEND_TRIM_KEY", "  padded-secret \n");
578        let router = BackendRouter::new(
579            &[static_env_backend("trim", "AV_TEST_BACKEND_TRIM_KEY")],
580            None,
581            None,
582        )
583        .unwrap();
584        std::env::remove_var("AV_TEST_BACKEND_TRIM_KEY");
585        let (_, value) = router.resolve("trim_tool").unwrap().auth_header.clone().unwrap();
586        assert_eq!(value.to_str().unwrap(), "Bearer padded-secret");
587        assert!(
588            value.is_sensitive(),
589            "credential headers must be excluded from logs and HPACK indexing"
590        );
591    }
592
593    #[test]
594    fn empty_or_missing_static_secrets_refuse_to_boot() {
595        std::env::set_var("AV_TEST_BACKEND_EMPTY_KEY", "   ");
596        let empty = BackendRouter::new(
597            &[static_env_backend("empty", "AV_TEST_BACKEND_EMPTY_KEY")],
598            None,
599            None,
600        );
601        std::env::remove_var("AV_TEST_BACKEND_EMPTY_KEY");
602        let error = empty.err().expect("whitespace-only secret must be refused");
603        assert!(error.contains("empty"), "got: {error}");
604
605        let missing = BackendRouter::new(
606            &[static_env_backend("missing", "AV_TEST_BACKEND_NEVER_SET_KEY")],
607            None,
608            None,
609        );
610        let error = missing.err().expect("unset env var must be refused");
611        assert!(error.contains("AV_TEST_BACKEND_NEVER_SET_KEY"), "got: {error}");
612    }
613
614    #[cfg(unix)]
615    #[test]
616    fn static_file_secret_is_trimmed_and_must_be_owner_only() {
617        use std::os::unix::fs::PermissionsExt as _;
618        let dir = tempfile::tempdir().unwrap();
619        let path = dir.path().join("backend.token");
620        std::fs::write(&path, "file-secret\n").unwrap();
621        let config = |p: &std::path::Path| BackendConfig {
622            name: "filed".into(),
623            url: "http://filed:8080".into(),
624            auth: BackendAuth::StaticFile(p.to_string_lossy().into_owned()),
625            tools: vec!["filed_tool".into()],
626            transport: Default::default(),
627        };
628
629        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
630        assert!(
631            BackendRouter::new(&[config(&path)], None, None).is_err(),
632            "a world-readable secret file must be refused"
633        );
634
635        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
636        let router = BackendRouter::new(&[config(&path)], None, None).unwrap();
637        let (_, value) = router.resolve("filed_tool").unwrap().auth_header.clone().unwrap();
638        assert_eq!(value.to_str().unwrap(), "Bearer file-secret");
639    }
640}