Skip to main content

soma_gateway/gateway/
protected_routes.rs

1use 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;