turul_http_mcp_server/
routes.rs1use async_trait::async_trait;
7use bytes::Bytes;
8use http_body_util::{BodyExt, Full};
9use hyper::{Request, Response, StatusCode};
10use std::sync::Arc;
11
12pub type RouteBody = http_body_util::combinators::UnsyncBoxBody<Bytes, hyper::Error>;
17
18#[async_trait]
20pub trait RouteHandler: Send + Sync {
21 async fn handle(&self, req: Request<RouteBody>) -> Response<RouteBody>;
23}
24
25pub struct RouteRegistry {
35 routes: Vec<(String, Arc<dyn RouteHandler>)>,
36}
37
38impl RouteRegistry {
39 pub fn new() -> Self {
41 Self { routes: Vec::new() }
42 }
43
44 pub fn add_route(&mut self, path: &str, handler: Arc<dyn RouteHandler>) {
51 self.routes.push((path.to_string(), handler));
52 }
53
54 pub fn is_empty(&self) -> bool {
56 self.routes.is_empty()
57 }
58
59 pub fn match_route(
64 &self,
65 path: &str,
66 ) -> Result<Option<&Arc<dyn RouteHandler>>, RouteValidationError> {
67 validate_path(path)?;
69
70 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#[derive(Debug, Clone, PartialEq)]
89pub enum RouteValidationError {
90 PathTraversal,
92 DoubleSlash,
94 EncodedSeparator,
96 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 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
128fn validate_path(path: &str) -> Result<(), RouteValidationError> {
137 if path.contains('\0') {
139 return Err(RouteValidationError::NullByte);
140 }
141
142 if path.contains("//") {
144 return Err(RouteValidationError::DoubleSlash);
145 }
146
147 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 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 if lower.contains("%252f") || lower.contains("%252e") {
166 return Err(RouteValidationError::EncodedSeparator);
167 }
168
169 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 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 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 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 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 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 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 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 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 assert!(matches!(
334 registry.match_route("/test%"),
335 Err(RouteValidationError::EncodedSeparator)
336 ));
337 assert!(matches!(
339 registry.match_route("/test%2"),
340 Err(RouteValidationError::EncodedSeparator)
341 ));
342 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}