soma_gateway/gateway/
protected_routes.rs1use serde_json::Value;
2use thiserror::Error;
3
4use crate::config::{ProtectedGatewaySubsetTarget, ProtectedMcpRouteConfig};
5use crate::security::ssrf::{validate_url, OutboundPolicy};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ProtectedRouteProjection {
9 pub name: String,
10 pub enabled: bool,
11 pub public_resource: String,
12 pub upstream: Option<String>,
13 pub connected: bool,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ProtectedRouteScope {
18 pub upstreams: Vec<String>,
19 pub services: Vec<String>,
20 pub expose_code_mode: bool,
21}
22
23#[derive(Debug, Error, PartialEq, Eq)]
24pub enum ProtectedRouteError {
25 #[error("host does not match protected route")]
26 HostMismatch,
27 #[error("path does not match protected route")]
28 PathMismatch,
29 #[error("backend URL is not allowed")]
30 BackendDenied,
31}
32
33pub fn project_route(route: &ProtectedMcpRouteConfig, connected: bool) -> ProtectedRouteProjection {
34 ProtectedRouteProjection {
35 name: route.name.clone(),
36 enabled: route.enabled,
37 public_resource: route.public_resource(),
38 upstream: route.upstream.clone(),
39 connected: route.enabled && connected,
40 }
41}
42
43pub fn route_matches(
44 route: &ProtectedMcpRouteConfig,
45 host_header: &str,
46 request_path: &str,
47) -> Result<(), ProtectedRouteError> {
48 if !host_matches(&route.public_host, host_header) {
49 return Err(ProtectedRouteError::HostMismatch);
50 }
51 if !path_matches(&route.public_path, request_path) {
52 return Err(ProtectedRouteError::PathMismatch);
53 }
54 Ok(())
55}
56
57pub fn validate_backend_for_dispatch(
58 route: &ProtectedMcpRouteConfig,
59) -> Result<(), ProtectedRouteError> {
60 if route.backend_url.trim().is_empty() {
61 return Ok(());
62 }
63 validate_url(&route.backend_url, OutboundPolicy::AdminProtectedBackend)
64 .map(|_| ())
65 .map_err(|_| ProtectedRouteError::BackendDenied)
66}
67
68pub fn resolve_scope(
69 route: &ProtectedMcpRouteConfig,
70 public_request_params: &Value,
71) -> ProtectedRouteScope {
72 let _ignored_public_scope = public_request_params.get("scope");
73 let target = route.target.as_ref().cloned().unwrap_or_default();
74 scope_from_target(&target)
75}
76
77pub fn protected_route_error_body(error: &ProtectedRouteError) -> String {
78 format!(r#"{{"error":"{error}"}}"#)
79}
80
81fn host_matches(configured: &str, header: &str) -> bool {
82 !header.contains(',')
83 && crate::config::protected_routes::normalize_public_host(configured)
84 == crate::config::protected_routes::normalize_public_host(header)
85}
86
87fn path_matches(public_path: &str, request_path: &str) -> bool {
88 if contains_encoded_slash_or_dot(request_path) {
89 return false;
90 }
91 let public_path = public_path.trim_end_matches('/');
92 request_path == public_path
93 || request_path
94 .strip_prefix(public_path)
95 .is_some_and(|rest| rest.starts_with('/'))
96}
97
98fn contains_encoded_slash_or_dot(path: &str) -> bool {
99 let lowercase = path.to_ascii_lowercase();
100 lowercase.contains("%2f") || lowercase.contains("%2e")
101}
102
103fn scope_from_target(target: &ProtectedGatewaySubsetTarget) -> ProtectedRouteScope {
104 ProtectedRouteScope {
105 upstreams: target.upstreams.clone(),
106 services: target.services.clone(),
107 expose_code_mode: target.expose_code_mode,
108 }
109}
110
111#[cfg(test)]
112#[path = "protected_routes_tests.rs"]
113mod tests;