Skip to main content

turul_http_mcp_server/
routes.rs

1//! Route registry for custom HTTP paths (e.g., `.well-known` endpoints)
2//!
3//! Provides exact-match routing with strict security validation.
4//! All paths are matched as raw strings — no normalization is performed.
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use http_body_util::{BodyExt, Full};
9use hyper::{Request, Response, StatusCode};
10use std::sync::Arc;
11
12/// Type-erased body used by route handlers.
13///
14/// Both `hyper::body::Incoming` (HTTP server) and `Full<Bytes>` (Lambda)
15/// can be boxed into this type, making route handlers transport-portable.
16pub type RouteBody = http_body_util::combinators::UnsyncBoxBody<Bytes, hyper::Error>;
17
18/// Handler for a custom HTTP route
19#[async_trait]
20pub trait RouteHandler: Send + Sync {
21    /// Handle an incoming HTTP request for this route
22    async fn handle(&self, req: Request<RouteBody>) -> Response<RouteBody>;
23}
24
25/// Registry of custom HTTP routes with strict path validation
26///
27/// # Security Contract
28///
29/// - **Exact-match only** — no prefix matching, no wildcards, no regex
30/// - **Case-sensitive** per RFC 8615
31/// - **Reject before matching** — path traversal, double slashes, percent-encoded
32///   separators, and null bytes are rejected with 400 Bad Request
33/// - **No normalization** — malicious paths are rejected, not normalized
34pub struct RouteRegistry {
35    routes: Vec<(String, Arc<dyn RouteHandler>)>,
36}
37
38impl RouteRegistry {
39    /// Create a new empty route registry
40    pub fn new() -> Self {
41        Self { routes: Vec::new() }
42    }
43
44    /// Add a route to the registry
45    ///
46    /// # Parameters
47    ///
48    /// - `path`: Exact path to match (e.g., `/.well-known/oauth-protected-resource`)
49    /// - `handler`: Handler to invoke when the path matches
50    pub fn add_route(&mut self, path: &str, handler: Arc<dyn RouteHandler>) {
51        self.routes.push((path.to_string(), handler));
52    }
53
54    /// Check if the registry has any routes
55    pub fn is_empty(&self) -> bool {
56        self.routes.is_empty()
57    }
58
59    /// Match a request path against registered routes
60    ///
61    /// Returns `None` if the path is rejected by security checks or doesn't match.
62    /// Returns the handler if an exact match is found.
63    pub fn match_route(
64        &self,
65        path: &str,
66    ) -> Result<Option<&Arc<dyn RouteHandler>>, RouteValidationError> {
67        // Security validation — reject before matching
68        validate_path(path)?;
69
70        // Exact match only
71        for (registered_path, handler) in &self.routes {
72            if path == registered_path {
73                return Ok(Some(handler));
74            }
75        }
76
77        Ok(None)
78    }
79}
80
81impl Default for RouteRegistry {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87/// Error returned when a request path fails security validation
88#[derive(Debug, Clone, PartialEq)]
89pub enum RouteValidationError {
90    /// Path contains path traversal sequences
91    PathTraversal,
92    /// Path contains double slashes
93    DoubleSlash,
94    /// Path contains percent-encoded separators or null bytes
95    EncodedSeparator,
96    /// Path contains null bytes
97    NullByte,
98}
99
100impl std::fmt::Display for RouteValidationError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Self::PathTraversal => write!(f, "Path traversal detected"),
104            Self::DoubleSlash => write!(f, "Double slash detected"),
105            Self::EncodedSeparator => write!(f, "Percent-encoded separator detected"),
106            Self::NullByte => write!(f, "Null byte detected"),
107        }
108    }
109}
110
111impl std::error::Error for RouteValidationError {}
112
113impl RouteValidationError {
114    /// Convert to an HTTP 400 Bad Request response
115    pub fn into_response(self) -> Response<RouteBody> {
116        Response::builder()
117            .status(StatusCode::BAD_REQUEST)
118            .header("Content-Type", "text/plain")
119            .body(
120                Full::new(Bytes::from(format!("Bad Request: {}", self)))
121                    .map_err(|never| match never {})
122                    .boxed_unsync(),
123            )
124            .unwrap()
125    }
126}
127
128/// Validate a request path against security rules.
129///
130/// Rejects:
131/// - Path traversal: `/../`, `/./`, trailing `..`
132/// - Double slashes: `//`
133/// - Percent-encoded separators: `%2f`, `%2F` (slash), `%2e` (dot), `%00` (null byte)
134/// - Double-encoded values: `%252f`, `%252e`
135/// - Null bytes: `\0` anywhere in path
136fn validate_path(path: &str) -> Result<(), RouteValidationError> {
137    // Null byte check (raw)
138    if path.contains('\0') {
139        return Err(RouteValidationError::NullByte);
140    }
141
142    // Double slash check
143    if path.contains("//") {
144        return Err(RouteValidationError::DoubleSlash);
145    }
146
147    // Path traversal check
148    if path.contains("/../")
149        || path.contains("/./")
150        || path.ends_with("/..")
151        || path.ends_with("/.")
152        || path == ".."
153        || path == "."
154    {
155        return Err(RouteValidationError::PathTraversal);
156    }
157
158    // Percent-encoded separator/null check (case-insensitive)
159    let lower = path.to_ascii_lowercase();
160    if lower.contains("%2f") || lower.contains("%2e") || lower.contains("%00") {
161        return Err(RouteValidationError::EncodedSeparator);
162    }
163
164    // Double-encoded check
165    if lower.contains("%252f") || lower.contains("%252e") {
166        return Err(RouteValidationError::EncodedSeparator);
167    }
168
169    // Malformed percent-encoding: % must be followed by exactly 2 hex digits
170    let bytes = path.as_bytes();
171    for i in 0..bytes.len() {
172        if bytes[i] == b'%'
173            && (i + 2 >= bytes.len()
174                || !bytes[i + 1].is_ascii_hexdigit()
175                || !bytes[i + 2].is_ascii_hexdigit())
176        {
177            return Err(RouteValidationError::EncodedSeparator);
178        }
179    }
180
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use async_trait::async_trait;
188
189    struct TestHandler {
190        body: String,
191    }
192
193    #[async_trait]
194    impl RouteHandler for TestHandler {
195        async fn handle(&self, _req: Request<RouteBody>) -> Response<RouteBody> {
196            Response::builder()
197                .status(StatusCode::OK)
198                .body(
199                    Full::new(Bytes::from(self.body.clone()))
200                        .map_err(|never| match never {})
201                        .boxed_unsync(),
202                )
203                .unwrap()
204        }
205    }
206
207    fn registry_with_well_known() -> RouteRegistry {
208        let mut registry = RouteRegistry::new();
209        registry.add_route(
210            "/.well-known/oauth-protected-resource",
211            Arc::new(TestHandler {
212                body: r#"{"resource":"https://example.com/mcp"}"#.to_string(),
213            }),
214        );
215        registry
216    }
217
218    #[test]
219    fn test_route_registry_exact_match() {
220        // T10: Exact match
221        let registry = registry_with_well_known();
222        let result = registry
223            .match_route("/.well-known/oauth-protected-resource")
224            .unwrap();
225        assert!(result.is_some());
226    }
227
228    #[test]
229    fn test_route_registry_no_prefix_match() {
230        // T11: No prefix matching
231        let registry = registry_with_well_known();
232        let result = registry
233            .match_route("/.well-known/oauth-protected-resource/extra")
234            .unwrap();
235        assert!(result.is_none());
236
237        let result = registry.match_route("/.well-known").unwrap();
238        assert!(result.is_none());
239    }
240
241    #[test]
242    fn test_route_registry_case_sensitive() {
243        // T12: Case sensitive
244        let registry = registry_with_well_known();
245        let result = registry
246            .match_route("/.Well-Known/OAuth-Protected-Resource")
247            .unwrap();
248        assert!(result.is_none());
249    }
250
251    #[test]
252    fn test_route_registry_reject_path_traversal() {
253        // T13: Path traversal rejection
254        let registry = registry_with_well_known();
255
256        assert!(matches!(
257            registry.match_route("/../.well-known/oauth-protected-resource"),
258            Err(RouteValidationError::PathTraversal)
259        ));
260        assert!(matches!(
261            registry.match_route("/.well-known/../admin"),
262            Err(RouteValidationError::PathTraversal)
263        ));
264        assert!(matches!(
265            registry.match_route("/./test"),
266            Err(RouteValidationError::PathTraversal)
267        ));
268        assert!(matches!(
269            registry.match_route("/test/.."),
270            Err(RouteValidationError::PathTraversal)
271        ));
272    }
273
274    #[test]
275    fn test_route_reject_percent_encoded_slash() {
276        // T38: Percent-encoded slash
277        let registry = registry_with_well_known();
278        assert!(matches!(
279            registry.match_route("/.well-known%2foauth-protected-resource"),
280            Err(RouteValidationError::EncodedSeparator)
281        ));
282        assert!(matches!(
283            registry.match_route("/.well-known%2Foauth-protected-resource"),
284            Err(RouteValidationError::EncodedSeparator)
285        ));
286    }
287
288    #[test]
289    fn test_route_reject_double_encoding() {
290        // T39: Double encoding
291        let registry = registry_with_well_known();
292        assert!(matches!(
293            registry.match_route("/.well-known%252foauth"),
294            Err(RouteValidationError::EncodedSeparator)
295        ));
296        assert!(matches!(
297            registry.match_route("/.well-known%252etest"),
298            Err(RouteValidationError::EncodedSeparator)
299        ));
300    }
301
302    #[test]
303    fn test_route_reject_null_byte() {
304        // T40: Null byte
305        let registry = registry_with_well_known();
306        assert!(matches!(
307            registry.match_route("/.well-known\x00/test"),
308            Err(RouteValidationError::NullByte)
309        ));
310    }
311
312    #[test]
313    fn test_route_reject_double_slash() {
314        // T41: Double slash
315        let registry = registry_with_well_known();
316        assert!(matches!(
317            registry.match_route("//.well-known/oauth-protected-resource"),
318            Err(RouteValidationError::DoubleSlash)
319        ));
320    }
321
322    #[test]
323    fn test_route_no_match_returns_none() {
324        let registry = registry_with_well_known();
325        let result = registry.match_route("/not-registered").unwrap();
326        assert!(result.is_none());
327    }
328
329    #[test]
330    fn test_route_reject_malformed_percent_encoding() {
331        let registry = registry_with_well_known();
332        // Trailing percent with no hex digits
333        assert!(matches!(
334            registry.match_route("/test%"),
335            Err(RouteValidationError::EncodedSeparator)
336        ));
337        // Percent with only one hex digit
338        assert!(matches!(
339            registry.match_route("/test%2"),
340            Err(RouteValidationError::EncodedSeparator)
341        ));
342        // Percent with non-hex characters
343        assert!(matches!(
344            registry.match_route("/test%ZZ"),
345            Err(RouteValidationError::EncodedSeparator)
346        ));
347        assert!(matches!(
348            registry.match_route("/test%G1"),
349            Err(RouteValidationError::EncodedSeparator)
350        ));
351    }
352
353    #[test]
354    fn test_empty_registry() {
355        let registry = RouteRegistry::new();
356        assert!(registry.is_empty());
357        let result = registry.match_route("/anything").unwrap();
358        assert!(result.is_none());
359    }
360}