Skip to main content

wowlab_sentinel/http/
mod.rs

1mod api_error;
2pub(crate) mod auth;
3mod routes;
4mod services;
5
6use std::sync::Arc;
7
8use axum::{
9    extract::{Request, State},
10    http::{HeaderValue, Method, header},
11    middleware::Next,
12    response::{IntoResponse, Response},
13};
14use serde::Serialize;
15use tokio_util::sync::CancellationToken;
16use tower_http::{
17    cors::{AllowOrigin, CorsLayer},
18    trace::TraceLayer,
19};
20
21use crate::state::ServerState;
22
23crate::db_client!(HttpDb, "http", max = 10);
24
25#[derive(Debug)]
26pub(crate) struct PrettyJson<T>(pub(crate) T);
27
28impl<T> IntoResponse for PrettyJson<T>
29where
30    T: Serialize,
31{
32    fn into_response(self) -> Response {
33        let body = serde_json::to_string_pretty(&self.0).expect("serializable response type");
34
35        ([(header::CONTENT_TYPE, "application/json")], body).into_response()
36    }
37}
38
39pub(crate) async fn run(
40    state: Arc<ServerState>,
41    shutdown: CancellationToken,
42) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
43    let port = state.config.http_port;
44    let patterns: Vec<String> = state.config.cors_origins.clone();
45    let cors = CorsLayer::new()
46        .allow_origin(AllowOrigin::predicate(move |origin: &HeaderValue, _| {
47            let Ok(origin) = origin.to_str() else {
48                return false;
49            };
50
51            patterns.iter().any(|p| origin_matches(origin, p))
52        }))
53        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
54        .allow_headers(cors_allowed_headers());
55
56    let app = routes::router(&state)
57        .layer(cors)
58        .layer(TraceLayer::new_for_http())
59        .with_state(state);
60
61    let listener = tokio::net::TcpListener::bind(format!("[::]:{port}")).await?;
62
63    tracing::info!(port, "HTTP server listening");
64
65    axum::serve(listener, app)
66        .with_graceful_shutdown(async move { shutdown.cancelled().await })
67        .await?;
68
69    Ok(())
70}
71
72pub(crate) async fn validate_mcp_origin(
73    State(state): State<Arc<ServerState>>,
74    request: Request,
75    next: Next,
76) -> Response {
77    match validate_origin(
78        request.headers().get(header::ORIGIN),
79        &state.config.cors_origins,
80    ) {
81        Ok(()) => next.run(request).await,
82        Err(status) => status.into_response(),
83    }
84}
85
86fn validate_origin(
87    origin: Option<&HeaderValue>,
88    allowed_patterns: &[String],
89) -> Result<(), axum::http::StatusCode> {
90    let Some(origin) = origin else {
91        return Ok(());
92    };
93    let Ok(origin) = origin.to_str() else {
94        return Err(axum::http::StatusCode::FORBIDDEN);
95    };
96
97    allowed_patterns
98        .iter()
99        .any(|pattern| origin_matches(origin, pattern))
100        .then_some(())
101        .ok_or(axum::http::StatusCode::FORBIDDEN)
102}
103
104fn origin_matches(origin: &str, pattern: &str) -> bool {
105    if !pattern.contains('*') {
106        return origin == pattern;
107    }
108
109    let parts: Vec<&str> = pattern.split('*').collect();
110
111    // #t(rust_magic_numbers) wildcard pattern split produces exactly 2 parts for a single '*'
112    if parts.len() != 2 {
113        return origin == pattern;
114    }
115
116    origin.starts_with(parts[0]) && origin.ends_with(parts[1])
117}
118
119fn cors_allowed_headers() -> Vec<header::HeaderName> {
120    vec![
121        header::ACCEPT,
122        header::AUTHORIZATION,
123        header::CONTENT_TYPE,
124        header::HeaderName::from_static("mcp-method"),
125        header::HeaderName::from_static("mcp-name"),
126        header::HeaderName::from_static("mcp-protocol-version"),
127        header::HeaderName::from_static("x-node-key"),
128        header::HeaderName::from_static("x-node-sig"),
129        header::HeaderName::from_static("x-node-ts"),
130    ]
131}
132
133#[cfg(test)]
134mod tests {
135    use googletest::prelude::*;
136
137    use super::*;
138
139    #[gtest]
140    fn mcp_origins_are_matched_against_configured_patterns() {
141        assert!(origin_matches(
142            "http://localhost:4317",
143            "http://localhost:*"
144        ));
145        assert!(origin_matches("https://wowlab.gg", "https://wowlab.gg"));
146        assert!(!origin_matches(
147            "https://attacker.example",
148            "https://wowlab.gg"
149        ));
150    }
151
152    #[gtest]
153    fn mcp_origin_policy_rejects_untrusted_and_malformed_origins() -> Result<()> {
154        let patterns = vec!["https://wowlab.gg".to_owned()];
155        let malformed = HeaderValue::from_bytes(&[0xff])?;
156
157        verify_that!(validate_origin(None, &patterns), ok(anything()))?;
158        verify_that!(
159            validate_origin(
160                Some(&HeaderValue::from_static("https://wowlab.gg")),
161                &patterns
162            ),
163            ok(anything())
164        )?;
165        verify_that!(
166            validate_origin(
167                Some(&HeaderValue::from_static("https://attacker.example")),
168                &patterns
169            ),
170            err(eq(axum::http::StatusCode::FORBIDDEN))
171        )?;
172
173        verify_that!(
174            validate_origin(Some(&malformed), &patterns),
175            err(eq(axum::http::StatusCode::FORBIDDEN))
176        )
177    }
178
179    #[gtest]
180    fn cors_allows_modern_mcp_headers() {
181        let headers = cors_allowed_headers();
182
183        for required in [
184            header::AUTHORIZATION,
185            header::HeaderName::from_static("mcp-protocol-version"),
186            header::HeaderName::from_static("mcp-method"),
187            header::HeaderName::from_static("mcp-name"),
188        ] {
189            assert!(headers.contains(&required), "missing {required}");
190        }
191    }
192}