Skip to main content

turul_mcp_aws_lambda/
cors.rs

1//! CORS (Cross-Origin Resource Sharing) support for Lambda MCP servers
2//!
3//! This module provides CORS header injection for Lambda responses, since Tower
4//! middleware cannot be used in the Lambda execution environment.
5
6use std::collections::HashSet;
7
8use http::{HeaderValue, Method};
9use lambda_http::{Body as LambdaBody, Response as LambdaResponse};
10use tracing::debug;
11
12use crate::error::{LambdaError, Result};
13
14/// CORS configuration for Lambda MCP servers
15#[derive(Debug, Clone)]
16pub struct CorsConfig {
17    /// Allowed origins for CORS requests
18    /// Use "*" to allow all origins (not recommended for production)
19    pub allowed_origins: Vec<String>,
20
21    /// Allowed HTTP methods
22    pub allowed_methods: Vec<Method>,
23
24    /// Allowed request headers
25    pub allowed_headers: Vec<String>,
26
27    /// Whether to allow credentials (cookies, authorization headers)
28    pub allow_credentials: bool,
29
30    /// Maximum age for preflight cache (in seconds)
31    pub max_age: Option<u32>,
32
33    /// Headers to expose to the client
34    pub expose_headers: Vec<String>,
35}
36
37impl Default for CorsConfig {
38    fn default() -> Self {
39        // `Mcp-Session-Id` exists only on the 2025-11-25 wire. The 2026-07-28
40        // stateless core removed protocol-level sessions: the transport ignores
41        // an inbound session header and never mints one, so advertising it to a
42        // browser would claim a contract this server does not honour.
43        let mut allowed_headers = vec![
44            "Content-Type".to_string(),
45            "Accept".to_string(),
46            "Authorization".to_string(),
47        ];
48        #[cfg(feature = "protocol-2025-11-25")]
49        allowed_headers.push("Mcp-Session-Id".to_string());
50        allowed_headers.push("Mcp-Protocol-Version".to_string());
51        allowed_headers.push("Last-Event-ID".to_string());
52
53        let mut expose_headers = vec!["Mcp-Protocol-Version".to_string()];
54        #[cfg(feature = "protocol-2025-11-25")]
55        expose_headers.push("Mcp-Session-Id".to_string());
56        // Exposed so browser OAuth clients can read the RFC 9728
57        // challenge on 401 responses (non-safelisted CORS header).
58        expose_headers.push("WWW-Authenticate".to_string());
59
60        Self {
61            allowed_origins: vec!["*".to_string()],
62            allowed_methods: vec![Method::GET, Method::POST, Method::DELETE, Method::OPTIONS],
63            allowed_headers,
64            allow_credentials: false,
65            max_age: Some(86400), // 24 hours
66            expose_headers,
67        }
68    }
69}
70
71impl CorsConfig {
72    /// Create a CORS config that allows all origins (for development)
73    pub fn allow_all() -> Self {
74        Self::default()
75    }
76
77    /// Create a CORS config for specific origins
78    pub fn for_origins(origins: Vec<String>) -> Self {
79        Self {
80            allowed_origins: origins,
81            ..Default::default()
82        }
83    }
84
85    /// Create a CORS config from environment variables
86    pub fn from_env() -> Self {
87        let allowed_origins = std::env::var("MCP_CORS_ORIGINS")
88            .map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
89            .unwrap_or_else(|_| vec!["*".to_string()]);
90
91        let allow_credentials = std::env::var("MCP_CORS_CREDENTIALS")
92            .map(|s| s.parse().unwrap_or(false))
93            .unwrap_or(false);
94
95        let max_age = std::env::var("MCP_CORS_MAX_AGE")
96            .ok()
97            .and_then(|s| s.parse().ok());
98
99        Self {
100            allowed_origins,
101            allow_credentials,
102            max_age,
103            ..Default::default()
104        }
105    }
106}
107
108/// Inject CORS headers into a Lambda response (generic over body type)
109///
110/// This function adds the appropriate CORS headers based on the configuration
111/// and the incoming request's Origin header.
112pub fn inject_cors_headers<B>(
113    response: &mut lambda_http::Response<B>,
114    config: &CorsConfig,
115    request_origin: Option<&str>,
116) -> Result<()> {
117    debug!("Injecting CORS headers for origin: {:?}", request_origin);
118
119    // Determine allowed origin
120    let allowed_origin = determine_allowed_origin(config, request_origin);
121
122    if let Some(origin) = allowed_origin {
123        response.headers_mut().insert(
124            "Access-Control-Allow-Origin",
125            HeaderValue::from_str(&origin)
126                .map_err(|e| LambdaError::Cors(format!("Invalid origin: {}", e)))?,
127        );
128    }
129
130    // Add allowed methods
131    let methods_str = config
132        .allowed_methods
133        .iter()
134        .map(|m| m.as_str())
135        .collect::<Vec<_>>()
136        .join(", ");
137    response.headers_mut().insert(
138        "Access-Control-Allow-Methods",
139        HeaderValue::from_str(&methods_str)
140            .map_err(|e| LambdaError::Cors(format!("Invalid methods: {}", e)))?,
141    );
142
143    // Add allowed headers
144    if !config.allowed_headers.is_empty() {
145        let headers_str = config.allowed_headers.join(", ");
146        response.headers_mut().insert(
147            "Access-Control-Allow-Headers",
148            HeaderValue::from_str(&headers_str)
149                .map_err(|e| LambdaError::Cors(format!("Invalid headers: {}", e)))?,
150        );
151    }
152
153    // Add exposed headers
154    if !config.expose_headers.is_empty() {
155        let expose_str = config.expose_headers.join(", ");
156        response.headers_mut().insert(
157            "Access-Control-Expose-Headers",
158            HeaderValue::from_str(&expose_str)
159                .map_err(|e| LambdaError::Cors(format!("Invalid expose headers: {}", e)))?,
160        );
161    }
162
163    // Add credentials if allowed
164    if config.allow_credentials {
165        response.headers_mut().insert(
166            "Access-Control-Allow-Credentials",
167            HeaderValue::from_static("true"),
168        );
169    }
170
171    // Add max age for preflight requests
172    if let Some(max_age) = config.max_age {
173        response.headers_mut().insert(
174            "Access-Control-Max-Age",
175            HeaderValue::from_str(&max_age.to_string())
176                .map_err(|e| LambdaError::Cors(format!("Invalid max age: {}", e)))?,
177        );
178    }
179
180    debug!("CORS headers injected successfully");
181    Ok(())
182}
183
184/// Create a CORS preflight response
185///
186/// Handles OPTIONS requests that browsers send before making actual CORS requests.
187pub fn create_preflight_response(
188    config: &CorsConfig,
189    request_origin: Option<&str>,
190) -> Result<LambdaResponse<LambdaBody>> {
191    debug!("Creating CORS preflight response");
192
193    let mut response = LambdaResponse::builder()
194        .status(200)
195        .body(LambdaBody::Empty)
196        .map_err(LambdaError::Http)?;
197
198    inject_cors_headers(&mut response, config, request_origin)?;
199
200    Ok(response)
201}
202
203/// Determine the allowed origin based on configuration and request
204fn determine_allowed_origin(config: &CorsConfig, request_origin: Option<&str>) -> Option<String> {
205    // If wildcard is configured, return it
206    if config.allowed_origins.contains(&"*".to_string()) {
207        return Some("*".to_string());
208    }
209
210    // If no origin in request, no CORS header needed
211    let request_origin = request_origin?;
212
213    // Check if the request origin is in the allowed list
214    if config.allowed_origins.contains(&request_origin.to_string()) {
215        Some(request_origin.to_string())
216    } else {
217        // Origin not allowed, don't set CORS header
218        None
219    }
220}
221
222/// Validate CORS configuration
223pub fn validate_config(config: &CorsConfig) -> Result<()> {
224    // Check for wildcard with credentials (security issue)
225    if config.allow_credentials && config.allowed_origins.contains(&"*".to_string()) {
226        return Err(LambdaError::Cors(
227            "Cannot use wildcard origin (*) with credentials enabled".to_string(),
228        ));
229    }
230
231    // Validate origins are proper URLs or wildcards
232    for origin in &config.allowed_origins {
233        if origin != "*" && !origin.starts_with("http://") && !origin.starts_with("https://") {
234            return Err(LambdaError::Cors(format!(
235                "Invalid origin format: {}",
236                origin
237            )));
238        }
239    }
240
241    // Check for duplicate headers
242    let headers_set: HashSet<_> = config.allowed_headers.iter().collect();
243    if headers_set.len() != config.allowed_headers.len() {
244        return Err(LambdaError::Cors(
245            "Duplicate headers in allowed_headers".to_string(),
246        ));
247    }
248
249    Ok(())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use lambda_http::Body;
256
257    #[test]
258    fn test_default_config() {
259        let config = CorsConfig::default();
260        assert!(config.allowed_origins.contains(&"*".to_string()));
261        assert!(config.allowed_methods.contains(&Method::GET));
262        assert!(config.allowed_methods.contains(&Method::POST));
263        assert!(config.allowed_headers.contains(&"Content-Type".to_string()));
264    }
265
266    #[test]
267    fn test_config_validation() {
268        let mut config = CorsConfig::default();
269        assert!(validate_config(&config).is_ok());
270
271        // Test invalid wildcard with credentials
272        config.allow_credentials = true;
273        assert!(validate_config(&config).is_err());
274
275        // Test invalid origin format
276        config.allow_credentials = false;
277        config.allowed_origins = vec!["invalid-origin".to_string()];
278        assert!(validate_config(&config).is_err());
279    }
280
281    #[tokio::test]
282    async fn test_cors_headers_injection() {
283        let config = CorsConfig::default();
284        let mut response = LambdaResponse::builder()
285            .status(200)
286            .body(Body::Empty)
287            .unwrap();
288
289        inject_cors_headers(&mut response, &config, Some("https://example.com")).unwrap();
290
291        assert_eq!(
292            response.headers().get("access-control-allow-origin"),
293            Some(&HeaderValue::from_static("*"))
294        );
295
296        assert!(
297            response
298                .headers()
299                .contains_key("access-control-allow-methods")
300        );
301        assert!(
302            response
303                .headers()
304                .contains_key("access-control-allow-headers")
305        );
306    }
307
308    #[test]
309    fn test_default_expose_headers_contains_www_authenticate() {
310        // Browsers can only read non-safelisted response headers when listed
311        // in Access-Control-Expose-Headers. RFC 9728 OAuth discovery requires
312        // clients to parse the WWW-Authenticate challenge on 401 responses,
313        // so it must be exposed by default for any browser-fronted MCP server.
314        let config = CorsConfig::default();
315        assert!(
316            config
317                .expose_headers
318                .iter()
319                .any(|h| h.eq_ignore_ascii_case("WWW-Authenticate")),
320            "default expose_headers must include WWW-Authenticate; got {:?}",
321            config.expose_headers,
322        );
323    }
324
325    #[tokio::test]
326    async fn test_custom_expose_headers_not_mutated_by_injection() {
327        // Caller-supplied expose_headers wins as-is. Injection must not
328        // augment or rewrite the list — consumers control the surface.
329        let config = CorsConfig {
330            expose_headers: vec!["X-Custom".to_string()],
331            ..Default::default()
332        };
333        let original = config.expose_headers.clone();
334
335        let mut response = LambdaResponse::builder()
336            .status(200)
337            .body(Body::Empty)
338            .unwrap();
339        inject_cors_headers(&mut response, &config, Some("https://example.com")).unwrap();
340
341        assert_eq!(config.expose_headers, original);
342        assert_eq!(
343            response.headers().get("access-control-expose-headers"),
344            Some(&HeaderValue::from_static("X-Custom"))
345        );
346    }
347
348    /// Collect a response header's comma-separated entries, lowercased.
349    fn header_entries(response: &LambdaResponse<Body>, name: &str) -> Vec<String> {
350        response
351            .headers()
352            .get(name)
353            .map(|v| {
354                v.to_str()
355                    .unwrap()
356                    .split(',')
357                    .map(|s| s.trim().to_ascii_lowercase())
358                    .collect()
359            })
360            .unwrap_or_default()
361    }
362
363    fn injected_default_response() -> LambdaResponse<Body> {
364        let config = CorsConfig::default();
365        let mut response = LambdaResponse::builder()
366            .status(200)
367            .body(Body::Empty)
368            .unwrap();
369        inject_cors_headers(&mut response, &config, Some("https://example.com")).unwrap();
370        response
371    }
372
373    #[cfg(feature = "protocol-2026-07-28")]
374    #[tokio::test]
375    async fn test_stateless_response_does_not_advertise_session_header() {
376        // 2026-07-28 removed protocol-level sessions. The transport ignores an
377        // inbound `Mcp-Session-Id` and never mints one, so neither the request
378        // allowlist nor the readable-response list may name it — a browser
379        // client would otherwise treat it as part of this server's contract.
380        let response = injected_default_response();
381
382        let allowed = header_entries(&response, "access-control-allow-headers");
383        assert!(
384            !allowed.iter().any(|h| h == "mcp-session-id"),
385            "2026-07-28 response must not advertise Mcp-Session-Id in \
386             Access-Control-Allow-Headers; got {allowed:?}",
387        );
388
389        let exposed = header_entries(&response, "access-control-expose-headers");
390        assert!(
391            !exposed.iter().any(|h| h == "mcp-session-id"),
392            "2026-07-28 response must not advertise Mcp-Session-Id in \
393             Access-Control-Expose-Headers; got {exposed:?}",
394        );
395
396        // The rest of the surface is unchanged — guards against a fix that
397        // empties the lists instead of removing one entry.
398        assert!(allowed.iter().any(|h| h == "mcp-protocol-version"));
399        assert!(exposed.iter().any(|h| h == "www-authenticate"));
400    }
401
402    #[cfg(feature = "protocol-2025-11-25")]
403    #[tokio::test]
404    async fn test_stateful_response_advertises_session_header() {
405        // 2025-11-25 clients MUST send `Mcp-Session-Id` after initialization
406        // and MUST read it off the initialize response, so both lists name it.
407        let response = injected_default_response();
408
409        assert!(
410            header_entries(&response, "access-control-allow-headers")
411                .iter()
412                .any(|h| h == "mcp-session-id"),
413        );
414        assert!(
415            header_entries(&response, "access-control-expose-headers")
416                .iter()
417                .any(|h| h == "mcp-session-id"),
418        );
419    }
420
421    #[tokio::test]
422    async fn test_preflight_response() {
423        let config = CorsConfig::default();
424        let response = create_preflight_response(&config, Some("https://example.com")).unwrap();
425
426        assert_eq!(response.status(), 200);
427        assert!(
428            response
429                .headers()
430                .contains_key("access-control-allow-origin")
431        );
432        assert!(
433            response
434                .headers()
435                .contains_key("access-control-allow-methods")
436        );
437    }
438}