we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement CORS: preflight, simple requests, credentialed requests

Add full CORS (Cross-Origin Resource Sharing) per the Fetch Standard:

- Simple request detection: CORS-safelisted methods (GET/HEAD/POST) and
headers (Accept, Accept-Language, Content-Language, Content-Type with
safelisted values) skip preflight
- Preflight (OPTIONS) requests for non-simple cross-origin requests with
Access-Control-Request-Method/Headers, validated against server response
- Preflight cache keyed by (origin, URL) with Access-Control-Max-Age TTL
- Access-Control-Allow-Origin validation (wildcard and exact match)
- Access-Control-Allow-Credentials enforcement (must be exact origin echo
and 'true' when credentials: include)
- Access-Control-Expose-Headers controls which response headers scripts see
- Response header filtering (only CORS-safelisted + explicitly exposed)
- Origin header sent on all cross-origin requests
- Fetch API supports mode (cors/no-cors) and credentials (omit/same-origin/
include) options

Integration points:
- crates/net/src/cors.rs: core CORS module (56 unit tests)
- crates/browser/src/loader.rs: CORS in resource loading pipeline
- crates/js/src/fetch.rs: CORS mode/credentials in Fetch API bridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1410 -118
+141 -79
crates/browser/src/loader.rs
··· 9 9 use we_encoding::sniff::sniff_encoding; 10 10 use we_encoding::Encoding; 11 11 use we_net::client::{ClientError, HttpClient}; 12 - use we_net::http::ContentType; 12 + use we_net::cors::{ 13 + self, build_preflight_headers, check_cors_response, needs_preflight, validate_preflight, 14 + CredentialsMode, PreflightCache, 15 + }; 16 + use we_net::http::{ContentType, Headers, Method}; 13 17 use we_url::data_url::{is_data_url, parse_data_url}; 14 18 use we_url::{Origin, Url}; 15 19 ··· 109 113 /// Loads resources over HTTP/HTTPS with encoding detection and content-type handling. 110 114 pub struct ResourceLoader { 111 115 client: HttpClient, 116 + preflight_cache: PreflightCache, 112 117 } 113 118 114 119 impl ResourceLoader { ··· 116 121 pub fn new() -> Self { 117 122 Self { 118 123 client: HttpClient::new(), 124 + preflight_cache: PreflightCache::new(), 119 125 } 120 126 } 121 127 ··· 205 211 } 206 212 } 207 213 208 - /// Fetch a subresource with Same-Origin Policy enforcement. 214 + /// Fetch a subresource with Same-Origin Policy and CORS enforcement. 209 215 /// 210 216 /// Checks the resource URL's origin against the document origin. For 211 - /// cross-origin requests without CORS headers, scripts, stylesheets, 212 - /// fetch, and font loads are blocked. Images and navigations are allowed. 217 + /// cross-origin requests, performs CORS checks including preflight for 218 + /// non-simple requests. Images and navigations are always allowed. 213 219 pub fn fetch_subresource( 214 220 &mut self, 215 221 url: &Url, 216 222 document_origin: &Origin, 217 223 request_type: ResourceRequestType, 218 224 ) -> Result<Resource, LoadError> { 225 + self.fetch_subresource_with_cors( 226 + url, 227 + document_origin, 228 + request_type, 229 + Method::Get, 230 + &Headers::new(), 231 + CredentialsMode::SameOrigin, 232 + ) 233 + } 234 + 235 + /// Fetch a subresource with full CORS control (method, headers, credentials). 236 + pub fn fetch_subresource_with_cors( 237 + &mut self, 238 + url: &Url, 239 + document_origin: &Origin, 240 + request_type: ResourceRequestType, 241 + method: Method, 242 + extra_headers: &Headers, 243 + credentials_mode: CredentialsMode, 244 + ) -> Result<Resource, LoadError> { 219 245 // data: and about: URLs are always allowed (local, no network). 220 246 if url.scheme() == "data" || url.scheme() == "about" { 221 247 return self.fetch(url); ··· 230 256 231 257 let resource_origin = url.origin(); 232 258 if document_origin.same_origin(&resource_origin) { 233 - return self.fetch(url); 259 + // Same-origin: no CORS needed, use provided method and headers. 260 + let response = self.client.request(method, url, extra_headers, None)?; 261 + if response.status_code >= 400 { 262 + return Err(LoadError::HttpStatus { 263 + status: response.status_code, 264 + reason: response.reason.clone(), 265 + }); 266 + } 267 + return decode_response(response, url); 234 268 } 235 269 236 - // Cross-origin: perform the fetch but check for CORS headers. 237 - let response = self.client.get(url)?; 270 + // Cross-origin: CORS flow. 271 + let url_str = url.serialize(); 272 + 273 + // Add Origin header to the request. 274 + let mut request_headers = Headers::new(); 275 + for (name, value) in extra_headers.iter() { 276 + request_headers.add(name, value); 277 + } 278 + request_headers.set("Origin", &document_origin.serialize()); 279 + 280 + // Check if a preflight is needed. 281 + if needs_preflight(method, extra_headers) { 282 + // Check the preflight cache first. 283 + if !self 284 + .preflight_cache 285 + .lookup(document_origin, &url_str, method, extra_headers) 286 + { 287 + // Send preflight OPTIONS request. 288 + let pf_headers = build_preflight_headers(document_origin, method, extra_headers); 289 + let pf_response = self 290 + .client 291 + .request(Method::Options, url, &pf_headers, None)?; 292 + 293 + let pf_result = 294 + validate_preflight(&pf_response.headers, document_origin, credentials_mode) 295 + .map_err(|reason| LoadError::CrossOriginBlocked { url: reason })?; 296 + 297 + // Check that the actual request's method/headers are allowed. 298 + cors::preflight_allows(&pf_result, method, extra_headers) 299 + .map_err(|reason| LoadError::CrossOriginBlocked { url: reason })?; 300 + 301 + // Cache the preflight result. 302 + self.preflight_cache 303 + .store(document_origin, &url_str, &pf_result); 304 + } 305 + } 306 + 307 + // Perform the actual request. 308 + let response = self.client.request(method, url, &request_headers, None)?; 238 309 239 310 if response.status_code >= 400 { 240 311 return Err(LoadError::HttpStatus { ··· 243 314 }); 244 315 } 245 316 246 - // Check Access-Control-Allow-Origin header. 247 - let doc_origin_str = document_origin.serialize(); 248 - let allowed = response 249 - .headers 250 - .get("access-control-allow-origin") 251 - .map(|v| { 252 - let v = v.trim(); 253 - v == "*" || v == doc_origin_str 254 - }) 255 - .unwrap_or(false); 256 - 257 - if !allowed { 258 - return Err(LoadError::CrossOriginBlocked { 259 - url: url.serialize(), 260 - }); 261 - } 262 - 263 - // CORS allows it — decode as normal. 264 - let content_type = response.content_type(); 265 - let mime = content_type 266 - .as_ref() 267 - .map(|ct| ct.mime_type.as_str()) 268 - .unwrap_or("application/octet-stream"); 317 + // Check CORS response headers. 318 + check_cors_response(&response.headers, document_origin, credentials_mode) 319 + .map_err(|reason| LoadError::CrossOriginBlocked { url: reason })?; 269 320 270 - match classify_mime(mime) { 271 - MimeClass::Html => { 272 - let (text, encoding) = 273 - decode_text_resource(&response.body, content_type.as_ref(), true); 274 - Ok(Resource::Html { 275 - text, 276 - base_url: url.clone(), 277 - encoding, 278 - }) 279 - } 280 - MimeClass::Css => { 281 - let (text, _encoding) = 282 - decode_text_resource(&response.body, content_type.as_ref(), false); 283 - Ok(Resource::Css { 284 - text, 285 - url: url.clone(), 286 - }) 287 - } 288 - MimeClass::Script => { 289 - let (text, _encoding) = 290 - decode_text_resource(&response.body, content_type.as_ref(), false); 291 - Ok(Resource::Script { 292 - text, 293 - url: url.clone(), 294 - }) 295 - } 296 - MimeClass::Image => Ok(Resource::Image { 297 - data: response.body, 298 - mime_type: mime.to_string(), 299 - url: url.clone(), 300 - }), 301 - MimeClass::Other => { 302 - if mime.starts_with("text/") { 303 - let (text, _encoding) = 304 - decode_text_resource(&response.body, content_type.as_ref(), false); 305 - Ok(Resource::Other { 306 - data: text.into_bytes(), 307 - mime_type: mime.to_string(), 308 - url: url.clone(), 309 - }) 310 - } else { 311 - Ok(Resource::Other { 312 - data: response.body, 313 - mime_type: mime.to_string(), 314 - url: url.clone(), 315 - }) 316 - } 317 - } 318 - } 321 + decode_response(response, url) 319 322 } 320 323 321 324 /// Fetch a URL string, resolving it against an optional base URL. ··· 377 380 impl Default for ResourceLoader { 378 381 fn default() -> Self { 379 382 Self::new() 383 + } 384 + } 385 + 386 + /// Decode an HTTP response into a Resource based on its Content-Type. 387 + fn decode_response(response: we_net::http::HttpResponse, url: &Url) -> Result<Resource, LoadError> { 388 + let content_type = response.content_type(); 389 + let mime = content_type 390 + .as_ref() 391 + .map(|ct| ct.mime_type.as_str()) 392 + .unwrap_or("application/octet-stream"); 393 + 394 + match classify_mime(mime) { 395 + MimeClass::Html => { 396 + let (text, encoding) = 397 + decode_text_resource(&response.body, content_type.as_ref(), true); 398 + Ok(Resource::Html { 399 + text, 400 + base_url: url.clone(), 401 + encoding, 402 + }) 403 + } 404 + MimeClass::Css => { 405 + let (text, _encoding) = 406 + decode_text_resource(&response.body, content_type.as_ref(), false); 407 + Ok(Resource::Css { 408 + text, 409 + url: url.clone(), 410 + }) 411 + } 412 + MimeClass::Script => { 413 + let (text, _encoding) = 414 + decode_text_resource(&response.body, content_type.as_ref(), false); 415 + Ok(Resource::Script { 416 + text, 417 + url: url.clone(), 418 + }) 419 + } 420 + MimeClass::Image => Ok(Resource::Image { 421 + data: response.body, 422 + mime_type: mime.to_string(), 423 + url: url.clone(), 424 + }), 425 + MimeClass::Other => { 426 + if mime.starts_with("text/") { 427 + let (text, _encoding) = 428 + decode_text_resource(&response.body, content_type.as_ref(), false); 429 + Ok(Resource::Other { 430 + data: text.into_bytes(), 431 + mime_type: mime.to_string(), 432 + url: url.clone(), 433 + }) 434 + } else { 435 + Ok(Resource::Other { 436 + data: response.body, 437 + mime_type: mime.to_string(), 438 + url: url.clone(), 439 + }) 440 + } 441 + } 380 442 } 381 443 } 382 444
+132 -38
crates/js/src/fetch.rs
··· 117 117 } 118 118 }; 119 119 120 - // Parse options (method, headers, body). 120 + // Parse options (method, headers, body, mode, credentials). 121 121 let mut method = "GET".to_string(); 122 122 let mut req_headers: Vec<(String, String)> = Vec::new(); 123 123 let mut body: Option<Vec<u8>> = None; 124 + let mut cors_mode = "cors".to_string(); 125 + let mut credentials_mode = "same-origin".to_string(); 124 126 125 127 if let Some(Value::Object(opts_ref)) = args.get(1) { 126 128 if let Some(HeapObject::Object(data)) = ctx.gc.get(*opts_ref) { ··· 150 152 } 151 153 } 152 154 } 155 + // mode 156 + if let Some(prop) = data.properties.get("mode") { 157 + if let Value::String(m) = &prop.value { 158 + cors_mode = m.clone(); 159 + } 160 + } 161 + // credentials 162 + if let Some(prop) = data.properties.get("credentials") { 163 + if let Value::String(c) = &prop.value { 164 + credentials_mode = c.clone(); 165 + } 166 + } 153 167 } 154 168 } 155 169 ··· 179 193 &req_headers, 180 194 body.as_deref(), 181 195 doc_origin.as_deref(), 196 + &cors_mode, 197 + &credentials_mode, 182 198 ); 183 199 let mut lock = slot_clone.lock().unwrap(); 184 200 *lock = Some(result); ··· 187 203 Ok(Value::Object(promise)) 188 204 } 189 205 206 + /// Parse a credentials mode string from JS into the CORS enum. 207 + fn parse_credentials_mode(mode: &str) -> we_net::cors::CredentialsMode { 208 + match mode { 209 + "include" => we_net::cors::CredentialsMode::Include, 210 + "omit" => we_net::cors::CredentialsMode::Omit, 211 + _ => we_net::cors::CredentialsMode::SameOrigin, 212 + } 213 + } 214 + 190 215 /// Perform the actual HTTP fetch (runs on a background thread). 191 216 /// 192 - /// If `document_origin` is set, performs Same-Origin Policy checks. Cross-origin 193 - /// responses without a matching `Access-Control-Allow-Origin` header are rejected. 217 + /// If `document_origin` is set, performs Same-Origin Policy and CORS checks. 218 + /// For non-simple cross-origin requests, sends a preflight OPTIONS first. 219 + /// Response headers are filtered per Access-Control-Expose-Headers. 194 220 fn do_fetch( 195 221 url_str: &str, 196 222 method: &str, 197 223 headers: &[(String, String)], 198 224 body: Option<&[u8]>, 199 225 document_origin: Option<&str>, 226 + cors_mode: &str, 227 + credentials_str: &str, 200 228 ) -> Result<FetchResult, String> { 201 229 let url = we_url::Url::parse(url_str).map_err(|e| format!("Invalid URL: {e}"))?; 202 230 ··· 216 244 other => return Err(format!("Unsupported HTTP method: {other}")), 217 245 }; 218 246 247 + let credentials_mode = parse_credentials_mode(credentials_str); 248 + 249 + // Determine if this is a cross-origin request. 250 + let is_cross_origin = if let Some(doc_origin) = document_origin { 251 + let resource_origin = url.origin(); 252 + let doc_parsed_origin = we_url::Url::parse(&format!("{doc_origin}/")) 253 + .map(|u| u.origin()) 254 + .unwrap_or(we_url::Origin::Opaque); 255 + !doc_parsed_origin.same_origin(&resource_origin) 256 + } else { 257 + false 258 + }; 259 + 219 260 let mut client = we_net::client::HttpClient::new(); 220 - let response = client 221 - .request(http_method, &url, &req_headers, body) 222 - .map_err(|e| format!("Network error: {e}"))?; 223 261 224 - // Same-Origin Policy check: if we have a document origin, verify 225 - // that cross-origin responses include a CORS header. 226 - if let Some(doc_origin) = document_origin { 227 - let resource_origin = url.origin(); 262 + if is_cross_origin && cors_mode == "cors" { 263 + let doc_origin = document_origin.unwrap(); 228 264 let doc_parsed_origin = we_url::Url::parse(&format!("{doc_origin}/")) 229 265 .map(|u| u.origin()) 230 266 .unwrap_or(we_url::Origin::Opaque); 231 267 232 - if !doc_parsed_origin.same_origin(&resource_origin) { 233 - // Cross-origin: check Access-Control-Allow-Origin. 234 - let cors_allowed = response 235 - .headers 236 - .get("access-control-allow-origin") 237 - .map(|v| { 238 - let v = v.trim(); 239 - v == "*" || v == doc_origin 240 - }) 241 - .unwrap_or(false); 268 + // Add Origin header. 269 + req_headers.set("Origin", doc_origin); 270 + 271 + // Check if preflight is needed. 272 + if we_net::cors::needs_preflight(http_method, &req_headers) { 273 + let pf_headers = we_net::cors::build_preflight_headers( 274 + &doc_parsed_origin, 275 + http_method, 276 + &req_headers, 277 + ); 278 + let pf_response = client 279 + .request(we_net::http::Method::Options, &url, &pf_headers, None) 280 + .map_err(|e| format!("Preflight error: {e}"))?; 242 281 243 - if !cors_allowed { 244 - return Err(format!( 245 - "Cross-origin request blocked: {url_str} (no CORS headers)" 246 - )); 247 - } 282 + let pf_result = we_net::cors::validate_preflight( 283 + &pf_response.headers, 284 + &doc_parsed_origin, 285 + credentials_mode, 286 + ) 287 + .map_err(|reason| format!("CORS preflight failed: {reason}"))?; 288 + 289 + we_net::cors::preflight_allows(&pf_result, http_method, &req_headers) 290 + .map_err(|reason| format!("CORS preflight rejected: {reason}"))?; 248 291 } 249 - } 292 + 293 + // Perform the actual request. 294 + let response = client 295 + .request(http_method, &url, &req_headers, body) 296 + .map_err(|e| format!("Network error: {e}"))?; 297 + 298 + // Check CORS response headers. 299 + let exposed = we_net::cors::check_cors_response( 300 + &response.headers, 301 + &doc_parsed_origin, 302 + credentials_mode, 303 + ) 304 + .map_err(|reason| format!("Cross-origin request blocked: {reason}"))?; 305 + 306 + // Filter response headers to only exposed ones. 307 + let filtered = we_net::cors::filter_response_headers(&response.headers, &exposed); 308 + let resp_headers: Vec<(String, String)> = filtered 309 + .iter() 310 + .map(|(k, v)| (k.to_string(), v.to_string())) 311 + .collect(); 312 + 313 + Ok(FetchResult { 314 + status: response.status_code, 315 + status_text: response.reason, 316 + headers: resp_headers, 317 + body: response.body, 318 + url: url.serialize(), 319 + }) 320 + } else if is_cross_origin && cors_mode == "no-cors" { 321 + // no-cors mode: make the request but return an opaque response. 322 + let _response = client 323 + .request(http_method, &url, &req_headers, body) 324 + .map_err(|e| format!("Network error: {e}"))?; 325 + 326 + Ok(FetchResult { 327 + status: 0, 328 + status_text: String::new(), 329 + headers: Vec::new(), 330 + body: Vec::new(), 331 + url: url.serialize(), 332 + }) 333 + } else if is_cross_origin { 334 + // Default: block cross-origin if not in cors mode. 335 + Err(format!( 336 + "Cross-origin request blocked: {url_str} (mode is '{cors_mode}')" 337 + )) 338 + } else { 339 + // Same-origin request. 340 + let response = client 341 + .request(http_method, &url, &req_headers, body) 342 + .map_err(|e| format!("Network error: {e}"))?; 250 343 251 - let resp_headers: Vec<(String, String)> = response 252 - .headers 253 - .iter() 254 - .map(|(k, v)| (k.to_string(), v.to_string())) 255 - .collect(); 344 + let resp_headers: Vec<(String, String)> = response 345 + .headers 346 + .iter() 347 + .map(|(k, v)| (k.to_string(), v.to_string())) 348 + .collect(); 256 349 257 - Ok(FetchResult { 258 - status: response.status_code, 259 - status_text: response.reason, 260 - headers: resp_headers, 261 - body: response.body, 262 - url: url.serialize(), 263 - }) 350 + Ok(FetchResult { 351 + status: response.status_code, 352 + status_text: response.reason, 353 + headers: resp_headers, 354 + body: response.body, 355 + url: url.serialize(), 356 + }) 357 + } 264 358 } 265 359 266 360 // ── Response object creation ────────────────────────────────────
+1135
crates/net/src/cors.rs
··· 1 + //! CORS (Cross-Origin Resource Sharing) per the Fetch Standard. 2 + //! 3 + //! Implements: 4 + //! - Simple request detection (CORS-safelisted methods and headers) 5 + //! - Preflight (OPTIONS) request construction and response validation 6 + //! - Preflight result caching with max-age 7 + //! - Access-Control-Allow-Origin / Allow-Credentials / Expose-Headers checks 8 + //! - Credentials mode handling 9 + 10 + use std::collections::HashMap; 11 + use std::time::{Duration, Instant}; 12 + 13 + use we_url::Origin; 14 + 15 + use crate::http::{Headers, Method}; 16 + 17 + // --------------------------------------------------------------------------- 18 + // CORS request mode 19 + // --------------------------------------------------------------------------- 20 + 21 + /// The CORS mode for a request. 22 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 + pub enum CorsMode { 24 + /// No CORS — request must be same-origin or the response is opaque. 25 + NoCors, 26 + /// CORS — cross-origin requests are allowed if the server opts in. 27 + Cors, 28 + /// Navigation — top-level document loads, always allowed. 29 + Navigate, 30 + } 31 + 32 + /// Credentials mode for a request. 33 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 34 + pub enum CredentialsMode { 35 + /// Never send credentials cross-origin. 36 + Omit, 37 + /// Send credentials only if same-origin. 38 + SameOrigin, 39 + /// Always send credentials (requires explicit server opt-in for cross-origin). 40 + Include, 41 + } 42 + 43 + // --------------------------------------------------------------------------- 44 + // CORS-safelisted checks 45 + // --------------------------------------------------------------------------- 46 + 47 + /// CORS-safelisted methods that do not trigger a preflight. 48 + const SIMPLE_METHODS: &[Method] = &[Method::Get, Method::Head, Method::Post]; 49 + 50 + /// CORS-safelisted request header names (lowercase). 51 + const SAFELISTED_HEADERS: &[&str] = &[ 52 + "accept", 53 + "accept-language", 54 + "content-language", 55 + "content-type", 56 + ]; 57 + 58 + /// Content-Type values that are CORS-safelisted. 59 + const SAFELISTED_CONTENT_TYPES: &[&str] = &[ 60 + "application/x-www-form-urlencoded", 61 + "multipart/form-data", 62 + "text/plain", 63 + ]; 64 + 65 + /// Check whether a method is CORS-safelisted (does not require preflight). 66 + pub fn is_simple_method(method: Method) -> bool { 67 + SIMPLE_METHODS.contains(&method) 68 + } 69 + 70 + /// Check whether a header name is CORS-safelisted. 71 + fn is_safelisted_header_name(name: &str) -> bool { 72 + SAFELISTED_HEADERS.contains(&name.to_ascii_lowercase().as_str()) 73 + } 74 + 75 + /// Check whether a Content-Type value is CORS-safelisted. 76 + fn is_safelisted_content_type(value: &str) -> bool { 77 + let mime = value 78 + .split(';') 79 + .next() 80 + .unwrap_or("") 81 + .trim() 82 + .to_ascii_lowercase(); 83 + SAFELISTED_CONTENT_TYPES.contains(&mime.as_str()) 84 + } 85 + 86 + /// Determine whether a request requires a CORS preflight. 87 + /// 88 + /// A preflight is required if: 89 + /// - The method is not GET, HEAD, or POST 90 + /// - Any request header is not CORS-safelisted 91 + /// - Content-Type (for POST) is not a safelisted value 92 + pub fn needs_preflight(method: Method, headers: &Headers) -> bool { 93 + if !is_simple_method(method) { 94 + return true; 95 + } 96 + 97 + for (name, value) in headers.iter() { 98 + let lower = name.to_ascii_lowercase(); 99 + if !is_safelisted_header_name(&lower) { 100 + return true; 101 + } 102 + // Content-Type must also have a safelisted value. 103 + if lower == "content-type" && !is_safelisted_content_type(value) { 104 + return true; 105 + } 106 + } 107 + 108 + false 109 + } 110 + 111 + // --------------------------------------------------------------------------- 112 + // Preflight request construction 113 + // --------------------------------------------------------------------------- 114 + 115 + /// Build the headers for a CORS preflight (OPTIONS) request. 116 + pub fn build_preflight_headers( 117 + origin: &Origin, 118 + method: Method, 119 + request_headers: &Headers, 120 + ) -> Headers { 121 + let mut headers = Headers::new(); 122 + headers.add("Origin", &origin.serialize()); 123 + headers.add("Access-Control-Request-Method", method.as_str()); 124 + 125 + // Collect non-safelisted header names for Access-Control-Request-Headers. 126 + let mut non_simple: Vec<String> = Vec::new(); 127 + for (name, value) in request_headers.iter() { 128 + let lower = name.to_ascii_lowercase(); 129 + if !is_safelisted_header_name(&lower) 130 + || (lower == "content-type" && !is_safelisted_content_type(value)) 131 + { 132 + non_simple.push(lower); 133 + } 134 + } 135 + if !non_simple.is_empty() { 136 + non_simple.sort(); 137 + non_simple.dedup(); 138 + headers.add("Access-Control-Request-Headers", &non_simple.join(", ")); 139 + } 140 + 141 + headers 142 + } 143 + 144 + // --------------------------------------------------------------------------- 145 + // Preflight response validation 146 + // --------------------------------------------------------------------------- 147 + 148 + /// Result of validating a preflight response. 149 + #[derive(Debug)] 150 + pub struct PreflightResult { 151 + /// Allowed methods from the preflight response. 152 + pub allowed_methods: Vec<String>, 153 + /// Allowed headers from the preflight response. 154 + pub allowed_headers: Vec<String>, 155 + /// Max-age for caching (seconds). Defaults to 5 seconds if not specified. 156 + pub max_age: u64, 157 + } 158 + 159 + /// Validate a preflight response and extract the allowed methods/headers. 160 + /// 161 + /// Returns `Err` with a human-readable reason if the preflight fails. 162 + pub fn validate_preflight( 163 + response_headers: &Headers, 164 + request_origin: &Origin, 165 + credentials_mode: CredentialsMode, 166 + ) -> Result<PreflightResult, String> { 167 + // Check Access-Control-Allow-Origin. 168 + let allow_origin = response_headers 169 + .get("access-control-allow-origin") 170 + .ok_or("preflight response missing Access-Control-Allow-Origin")?; 171 + 172 + let origin_str = request_origin.serialize(); 173 + let allow_origin = allow_origin.trim(); 174 + 175 + if credentials_mode == CredentialsMode::Include { 176 + // With credentials, wildcard is not allowed. 177 + if allow_origin != origin_str { 178 + return Err(format!( 179 + "preflight: Access-Control-Allow-Origin must be '{origin_str}' \ 180 + when credentials are included, got '{allow_origin}'" 181 + )); 182 + } 183 + // Must also have Allow-Credentials: true. 184 + let allow_creds = response_headers 185 + .get("access-control-allow-credentials") 186 + .unwrap_or(""); 187 + if allow_creds.trim() != "true" { 188 + return Err( 189 + "preflight: Access-Control-Allow-Credentials must be 'true' \ 190 + when credentials are included" 191 + .to_string(), 192 + ); 193 + } 194 + } else if allow_origin != "*" && allow_origin != origin_str { 195 + return Err(format!( 196 + "preflight: Access-Control-Allow-Origin '{allow_origin}' \ 197 + does not match origin '{origin_str}'" 198 + )); 199 + } 200 + 201 + // Parse Access-Control-Allow-Methods. 202 + let allowed_methods: Vec<String> = response_headers 203 + .get("access-control-allow-methods") 204 + .unwrap_or("") 205 + .split(',') 206 + .map(|s| s.trim().to_uppercase()) 207 + .filter(|s| !s.is_empty()) 208 + .collect(); 209 + 210 + // Parse Access-Control-Allow-Headers. 211 + let allowed_headers: Vec<String> = response_headers 212 + .get("access-control-allow-headers") 213 + .unwrap_or("") 214 + .split(',') 215 + .map(|s| s.trim().to_ascii_lowercase()) 216 + .filter(|s| !s.is_empty()) 217 + .collect(); 218 + 219 + // Parse Access-Control-Max-Age (default to 5 seconds). 220 + let max_age = response_headers 221 + .get("access-control-max-age") 222 + .and_then(|v| v.trim().parse::<u64>().ok()) 223 + .unwrap_or(5); 224 + 225 + Ok(PreflightResult { 226 + allowed_methods, 227 + allowed_headers, 228 + max_age, 229 + }) 230 + } 231 + 232 + /// Check whether the actual request's method and headers are allowed by 233 + /// the preflight result. 234 + pub fn preflight_allows( 235 + result: &PreflightResult, 236 + method: Method, 237 + request_headers: &Headers, 238 + ) -> Result<(), String> { 239 + let method_str = method.as_str().to_uppercase(); 240 + 241 + // Simple methods are always allowed even if not listed. 242 + if !is_simple_method(method) 243 + && !result.allowed_methods.iter().any(|m| m == &method_str) 244 + && !result.allowed_methods.contains(&"*".to_string()) 245 + { 246 + return Err(format!( 247 + "CORS: method {method_str} not allowed by preflight" 248 + )); 249 + } 250 + 251 + // Check non-safelisted headers. 252 + for (name, value) in request_headers.iter() { 253 + let lower = name.to_ascii_lowercase(); 254 + if is_safelisted_header_name(&lower) { 255 + if lower == "content-type" && !is_safelisted_content_type(value) { 256 + // content-type with non-simple value needs to be allowed. 257 + if !result.allowed_headers.contains(&lower) 258 + && !result.allowed_headers.contains(&"*".to_string()) 259 + { 260 + return Err(format!( 261 + "CORS: header '{lower}' with value '{value}' not allowed by preflight" 262 + )); 263 + } 264 + } 265 + continue; 266 + } 267 + if !result.allowed_headers.contains(&lower) 268 + && !result.allowed_headers.contains(&"*".to_string()) 269 + { 270 + return Err(format!("CORS: header '{lower}' not allowed by preflight")); 271 + } 272 + } 273 + 274 + Ok(()) 275 + } 276 + 277 + // --------------------------------------------------------------------------- 278 + // CORS response check 279 + // --------------------------------------------------------------------------- 280 + 281 + /// CORS-safelisted response header names that are always exposed. 282 + const SAFELISTED_RESPONSE_HEADERS: &[&str] = &[ 283 + "cache-control", 284 + "content-language", 285 + "content-length", 286 + "content-type", 287 + "expires", 288 + "last-modified", 289 + "pragma", 290 + ]; 291 + 292 + /// Check the Access-Control-Allow-Origin on the actual response. 293 + /// 294 + /// Returns `Ok(exposed_headers)` with the set of response header names 295 + /// the script is allowed to read, or `Err` if the response is blocked. 296 + pub fn check_cors_response( 297 + response_headers: &Headers, 298 + request_origin: &Origin, 299 + credentials_mode: CredentialsMode, 300 + ) -> Result<Vec<String>, String> { 301 + let allow_origin = response_headers 302 + .get("access-control-allow-origin") 303 + .ok_or("CORS: response missing Access-Control-Allow-Origin")?; 304 + 305 + let origin_str = request_origin.serialize(); 306 + let allow_origin = allow_origin.trim(); 307 + 308 + if credentials_mode == CredentialsMode::Include { 309 + if allow_origin != origin_str { 310 + return Err(format!( 311 + "CORS: Access-Control-Allow-Origin must be '{origin_str}' \ 312 + when credentials are included, got '{allow_origin}'" 313 + )); 314 + } 315 + let allow_creds = response_headers 316 + .get("access-control-allow-credentials") 317 + .unwrap_or(""); 318 + if allow_creds.trim() != "true" { 319 + return Err("CORS: Access-Control-Allow-Credentials must be 'true' \ 320 + when credentials are included" 321 + .to_string()); 322 + } 323 + } else if allow_origin != "*" && allow_origin != origin_str { 324 + return Err(format!( 325 + "CORS: Access-Control-Allow-Origin '{allow_origin}' \ 326 + does not match origin '{origin_str}'" 327 + )); 328 + } 329 + 330 + // Build list of exposed headers. 331 + let mut exposed: Vec<String> = SAFELISTED_RESPONSE_HEADERS 332 + .iter() 333 + .map(|s| s.to_string()) 334 + .collect(); 335 + 336 + if let Some(expose_hdr) = response_headers.get("access-control-expose-headers") { 337 + for name in expose_hdr.split(',') { 338 + let name = name.trim().to_ascii_lowercase(); 339 + if !name.is_empty() { 340 + if name == "*" && credentials_mode != CredentialsMode::Include { 341 + // Wildcard: expose all headers. 342 + for (h, _) in response_headers.iter() { 343 + let lower = h.to_ascii_lowercase(); 344 + if !exposed.contains(&lower) { 345 + exposed.push(lower); 346 + } 347 + } 348 + break; 349 + } 350 + if !exposed.contains(&name) { 351 + exposed.push(name); 352 + } 353 + } 354 + } 355 + } 356 + 357 + Ok(exposed) 358 + } 359 + 360 + /// Filter response headers to only those the script is allowed to see. 361 + pub fn filter_response_headers(headers: &Headers, exposed: &[String]) -> Headers { 362 + let mut filtered = Headers::new(); 363 + for (name, value) in headers.iter() { 364 + let lower = name.to_ascii_lowercase(); 365 + if exposed.contains(&lower) { 366 + filtered.add(name, value); 367 + } 368 + } 369 + filtered 370 + } 371 + 372 + // --------------------------------------------------------------------------- 373 + // Preflight cache 374 + // --------------------------------------------------------------------------- 375 + 376 + /// Key for the preflight cache. 377 + #[derive(Hash, Eq, PartialEq, Clone, Debug)] 378 + struct PreflightCacheKey { 379 + origin: String, 380 + url: String, 381 + } 382 + 383 + /// An entry in the preflight cache. 384 + struct PreflightCacheEntry { 385 + allowed_methods: Vec<String>, 386 + allowed_headers: Vec<String>, 387 + created: Instant, 388 + max_age: Duration, 389 + } 390 + 391 + /// Cache for CORS preflight results. 392 + /// 393 + /// Keyed by (request origin, URL). Entries expire after their max-age. 394 + pub struct PreflightCache { 395 + entries: HashMap<PreflightCacheKey, PreflightCacheEntry>, 396 + } 397 + 398 + impl PreflightCache { 399 + /// Create a new, empty preflight cache. 400 + pub fn new() -> Self { 401 + Self { 402 + entries: HashMap::new(), 403 + } 404 + } 405 + 406 + /// Store a preflight result in the cache. 407 + pub fn store(&mut self, origin: &Origin, url: &str, result: &PreflightResult) { 408 + let key = PreflightCacheKey { 409 + origin: origin.serialize(), 410 + url: url.to_string(), 411 + }; 412 + self.entries.insert( 413 + key, 414 + PreflightCacheEntry { 415 + allowed_methods: result.allowed_methods.clone(), 416 + allowed_headers: result.allowed_headers.clone(), 417 + created: Instant::now(), 418 + max_age: Duration::from_secs(result.max_age), 419 + }, 420 + ); 421 + } 422 + 423 + /// Look up a cached preflight result. 424 + /// 425 + /// Returns `Some` if a valid (non-expired) entry exists for the 426 + /// given origin and URL, and the entry allows the requested method 427 + /// and headers. 428 + pub fn lookup( 429 + &mut self, 430 + origin: &Origin, 431 + url: &str, 432 + method: Method, 433 + headers: &Headers, 434 + ) -> bool { 435 + let key = PreflightCacheKey { 436 + origin: origin.serialize(), 437 + url: url.to_string(), 438 + }; 439 + 440 + let entry = match self.entries.get(&key) { 441 + Some(e) => e, 442 + None => return false, 443 + }; 444 + 445 + // Check expiration. 446 + if entry.created.elapsed() > entry.max_age { 447 + self.entries.remove(&key); 448 + return false; 449 + } 450 + 451 + // Check method. 452 + let method_str = method.as_str().to_uppercase(); 453 + if !is_simple_method(method) 454 + && !entry.allowed_methods.iter().any(|m| m == &method_str) 455 + && !entry.allowed_methods.contains(&"*".to_string()) 456 + { 457 + return false; 458 + } 459 + 460 + // Check non-safelisted headers. 461 + for (name, value) in headers.iter() { 462 + let lower = name.to_ascii_lowercase(); 463 + if is_safelisted_header_name(&lower) { 464 + if lower == "content-type" 465 + && !is_safelisted_content_type(value) 466 + && !entry.allowed_headers.contains(&lower) 467 + && !entry.allowed_headers.contains(&"*".to_string()) 468 + { 469 + return false; 470 + } 471 + continue; 472 + } 473 + if !entry.allowed_headers.contains(&lower) 474 + && !entry.allowed_headers.contains(&"*".to_string()) 475 + { 476 + return false; 477 + } 478 + } 479 + 480 + true 481 + } 482 + 483 + /// Remove expired entries from the cache. 484 + pub fn evict_expired(&mut self) { 485 + self.entries.retain(|_, e| e.created.elapsed() <= e.max_age); 486 + } 487 + } 488 + 489 + impl Default for PreflightCache { 490 + fn default() -> Self { 491 + Self::new() 492 + } 493 + } 494 + 495 + // --------------------------------------------------------------------------- 496 + // Tests 497 + // --------------------------------------------------------------------------- 498 + 499 + #[cfg(test)] 500 + mod tests { 501 + use super::*; 502 + use we_url::Host; 503 + 504 + fn make_origin(scheme: &str, domain: &str, port: Option<u16>) -> Origin { 505 + Origin::Tuple(scheme.to_string(), Host::Domain(domain.to_string()), port) 506 + } 507 + 508 + // -- Simple method checks -- 509 + 510 + #[test] 511 + fn get_is_simple() { 512 + assert!(is_simple_method(Method::Get)); 513 + } 514 + 515 + #[test] 516 + fn head_is_simple() { 517 + assert!(is_simple_method(Method::Head)); 518 + } 519 + 520 + #[test] 521 + fn post_is_simple() { 522 + assert!(is_simple_method(Method::Post)); 523 + } 524 + 525 + #[test] 526 + fn put_is_not_simple() { 527 + assert!(!is_simple_method(Method::Put)); 528 + } 529 + 530 + #[test] 531 + fn delete_is_not_simple() { 532 + assert!(!is_simple_method(Method::Delete)); 533 + } 534 + 535 + #[test] 536 + fn patch_is_not_simple() { 537 + assert!(!is_simple_method(Method::Patch)); 538 + } 539 + 540 + #[test] 541 + fn options_is_not_simple() { 542 + assert!(!is_simple_method(Method::Options)); 543 + } 544 + 545 + // -- needs_preflight -- 546 + 547 + #[test] 548 + fn simple_get_no_preflight() { 549 + let headers = Headers::new(); 550 + assert!(!needs_preflight(Method::Get, &headers)); 551 + } 552 + 553 + #[test] 554 + fn simple_post_form_no_preflight() { 555 + let mut headers = Headers::new(); 556 + headers.add("Content-Type", "application/x-www-form-urlencoded"); 557 + assert!(!needs_preflight(Method::Post, &headers)); 558 + } 559 + 560 + #[test] 561 + fn post_json_needs_preflight() { 562 + let mut headers = Headers::new(); 563 + headers.add("Content-Type", "application/json"); 564 + assert!(needs_preflight(Method::Post, &headers)); 565 + } 566 + 567 + #[test] 568 + fn put_needs_preflight() { 569 + let headers = Headers::new(); 570 + assert!(needs_preflight(Method::Put, &headers)); 571 + } 572 + 573 + #[test] 574 + fn custom_header_needs_preflight() { 575 + let mut headers = Headers::new(); 576 + headers.add("X-Custom", "value"); 577 + assert!(needs_preflight(Method::Get, &headers)); 578 + } 579 + 580 + #[test] 581 + fn accept_header_no_preflight() { 582 + let mut headers = Headers::new(); 583 + headers.add("Accept", "application/json"); 584 + assert!(!needs_preflight(Method::Get, &headers)); 585 + } 586 + 587 + #[test] 588 + fn authorization_header_needs_preflight() { 589 + let mut headers = Headers::new(); 590 + headers.add("Authorization", "Bearer token123"); 591 + assert!(needs_preflight(Method::Get, &headers)); 592 + } 593 + 594 + // -- build_preflight_headers -- 595 + 596 + #[test] 597 + fn preflight_headers_basic() { 598 + let origin = make_origin("https", "example.com", None); 599 + let mut req_headers = Headers::new(); 600 + req_headers.add("X-Custom", "foo"); 601 + 602 + let pf = build_preflight_headers(&origin, Method::Put, &req_headers); 603 + assert_eq!(pf.get("Origin").unwrap(), "https://example.com"); 604 + assert_eq!(pf.get("Access-Control-Request-Method").unwrap(), "PUT"); 605 + assert_eq!( 606 + pf.get("Access-Control-Request-Headers").unwrap(), 607 + "x-custom" 608 + ); 609 + } 610 + 611 + #[test] 612 + fn preflight_headers_no_extra_headers() { 613 + let origin = make_origin("https", "example.com", None); 614 + let req_headers = Headers::new(); 615 + 616 + let pf = build_preflight_headers(&origin, Method::Delete, &req_headers); 617 + assert_eq!(pf.get("Origin").unwrap(), "https://example.com"); 618 + assert_eq!(pf.get("Access-Control-Request-Method").unwrap(), "DELETE"); 619 + assert!(pf.get("Access-Control-Request-Headers").is_none()); 620 + } 621 + 622 + #[test] 623 + fn preflight_headers_multiple_custom() { 624 + let origin = make_origin("https", "example.com", None); 625 + let mut req_headers = Headers::new(); 626 + req_headers.add("X-B", "2"); 627 + req_headers.add("X-A", "1"); 628 + 629 + let pf = build_preflight_headers(&origin, Method::Post, &req_headers); 630 + let requested = pf.get("Access-Control-Request-Headers").unwrap(); 631 + // Should be sorted and deduplicated. 632 + assert_eq!(requested, "x-a, x-b"); 633 + } 634 + 635 + #[test] 636 + fn preflight_headers_non_simple_content_type() { 637 + let origin = make_origin("https", "example.com", None); 638 + let mut req_headers = Headers::new(); 639 + req_headers.add("Content-Type", "application/json"); 640 + 641 + let pf = build_preflight_headers(&origin, Method::Post, &req_headers); 642 + assert_eq!( 643 + pf.get("Access-Control-Request-Headers").unwrap(), 644 + "content-type" 645 + ); 646 + } 647 + 648 + // -- validate_preflight -- 649 + 650 + #[test] 651 + fn validate_preflight_wildcard_origin() { 652 + let origin = make_origin("https", "example.com", None); 653 + let mut headers = Headers::new(); 654 + headers.add("Access-Control-Allow-Origin", "*"); 655 + headers.add("Access-Control-Allow-Methods", "PUT, DELETE"); 656 + headers.add("Access-Control-Allow-Headers", "x-custom"); 657 + headers.add("Access-Control-Max-Age", "600"); 658 + 659 + let result = validate_preflight(&headers, &origin, CredentialsMode::Omit).unwrap(); 660 + assert!(result.allowed_methods.contains(&"PUT".to_string())); 661 + assert!(result.allowed_methods.contains(&"DELETE".to_string())); 662 + assert!(result.allowed_headers.contains(&"x-custom".to_string())); 663 + assert_eq!(result.max_age, 600); 664 + } 665 + 666 + #[test] 667 + fn validate_preflight_exact_origin() { 668 + let origin = make_origin("https", "example.com", None); 669 + let mut headers = Headers::new(); 670 + headers.add("Access-Control-Allow-Origin", "https://example.com"); 671 + headers.add("Access-Control-Allow-Methods", "POST"); 672 + 673 + let result = validate_preflight(&headers, &origin, CredentialsMode::Omit).unwrap(); 674 + assert!(result.allowed_methods.contains(&"POST".to_string())); 675 + } 676 + 677 + #[test] 678 + fn validate_preflight_wrong_origin() { 679 + let origin = make_origin("https", "example.com", None); 680 + let mut headers = Headers::new(); 681 + headers.add("Access-Control-Allow-Origin", "https://other.com"); 682 + 683 + let result = validate_preflight(&headers, &origin, CredentialsMode::Omit); 684 + assert!(result.is_err()); 685 + } 686 + 687 + #[test] 688 + fn validate_preflight_missing_allow_origin() { 689 + let origin = make_origin("https", "example.com", None); 690 + let headers = Headers::new(); 691 + 692 + let result = validate_preflight(&headers, &origin, CredentialsMode::Omit); 693 + assert!(result.is_err()); 694 + } 695 + 696 + #[test] 697 + fn validate_preflight_credentials_wildcard_rejected() { 698 + let origin = make_origin("https", "example.com", None); 699 + let mut headers = Headers::new(); 700 + headers.add("Access-Control-Allow-Origin", "*"); 701 + headers.add("Access-Control-Allow-Credentials", "true"); 702 + 703 + let result = validate_preflight(&headers, &origin, CredentialsMode::Include); 704 + assert!(result.is_err()); 705 + } 706 + 707 + #[test] 708 + fn validate_preflight_credentials_exact_origin() { 709 + let origin = make_origin("https", "example.com", None); 710 + let mut headers = Headers::new(); 711 + headers.add("Access-Control-Allow-Origin", "https://example.com"); 712 + headers.add("Access-Control-Allow-Credentials", "true"); 713 + headers.add("Access-Control-Allow-Methods", "POST"); 714 + 715 + let result = validate_preflight(&headers, &origin, CredentialsMode::Include).unwrap(); 716 + assert!(result.allowed_methods.contains(&"POST".to_string())); 717 + } 718 + 719 + #[test] 720 + fn validate_preflight_credentials_missing_allow_creds() { 721 + let origin = make_origin("https", "example.com", None); 722 + let mut headers = Headers::new(); 723 + headers.add("Access-Control-Allow-Origin", "https://example.com"); 724 + 725 + let result = validate_preflight(&headers, &origin, CredentialsMode::Include); 726 + assert!(result.is_err()); 727 + } 728 + 729 + #[test] 730 + fn validate_preflight_default_max_age() { 731 + let origin = make_origin("https", "example.com", None); 732 + let mut headers = Headers::new(); 733 + headers.add("Access-Control-Allow-Origin", "*"); 734 + 735 + let result = validate_preflight(&headers, &origin, CredentialsMode::Omit).unwrap(); 736 + assert_eq!(result.max_age, 5); 737 + } 738 + 739 + // -- preflight_allows -- 740 + 741 + #[test] 742 + fn preflight_allows_simple_method() { 743 + let result = PreflightResult { 744 + allowed_methods: vec![], 745 + allowed_headers: vec![], 746 + max_age: 5, 747 + }; 748 + // GET is always allowed even without being listed. 749 + assert!(preflight_allows(&result, Method::Get, &Headers::new()).is_ok()); 750 + } 751 + 752 + #[test] 753 + fn preflight_allows_listed_method() { 754 + let result = PreflightResult { 755 + allowed_methods: vec!["PUT".to_string()], 756 + allowed_headers: vec![], 757 + max_age: 5, 758 + }; 759 + assert!(preflight_allows(&result, Method::Put, &Headers::new()).is_ok()); 760 + } 761 + 762 + #[test] 763 + fn preflight_rejects_unlisted_method() { 764 + let result = PreflightResult { 765 + allowed_methods: vec!["PUT".to_string()], 766 + allowed_headers: vec![], 767 + max_age: 5, 768 + }; 769 + assert!(preflight_allows(&result, Method::Delete, &Headers::new()).is_err()); 770 + } 771 + 772 + #[test] 773 + fn preflight_allows_listed_header() { 774 + let result = PreflightResult { 775 + allowed_methods: vec![], 776 + allowed_headers: vec!["x-custom".to_string()], 777 + max_age: 5, 778 + }; 779 + let mut headers = Headers::new(); 780 + headers.add("X-Custom", "value"); 781 + assert!(preflight_allows(&result, Method::Get, &headers).is_ok()); 782 + } 783 + 784 + #[test] 785 + fn preflight_rejects_unlisted_header() { 786 + let result = PreflightResult { 787 + allowed_methods: vec![], 788 + allowed_headers: vec!["x-other".to_string()], 789 + max_age: 5, 790 + }; 791 + let mut headers = Headers::new(); 792 + headers.add("X-Custom", "value"); 793 + assert!(preflight_allows(&result, Method::Get, &headers).is_err()); 794 + } 795 + 796 + #[test] 797 + fn preflight_allows_wildcard_method() { 798 + let result = PreflightResult { 799 + allowed_methods: vec!["*".to_string()], 800 + allowed_headers: vec![], 801 + max_age: 5, 802 + }; 803 + assert!(preflight_allows(&result, Method::Delete, &Headers::new()).is_ok()); 804 + } 805 + 806 + #[test] 807 + fn preflight_allows_wildcard_header() { 808 + let result = PreflightResult { 809 + allowed_methods: vec![], 810 + allowed_headers: vec!["*".to_string()], 811 + max_age: 5, 812 + }; 813 + let mut headers = Headers::new(); 814 + headers.add("X-Anything", "yes"); 815 + assert!(preflight_allows(&result, Method::Get, &headers).is_ok()); 816 + } 817 + 818 + // -- check_cors_response -- 819 + 820 + #[test] 821 + fn cors_response_wildcard_origin() { 822 + let origin = make_origin("https", "example.com", None); 823 + let mut headers = Headers::new(); 824 + headers.add("Access-Control-Allow-Origin", "*"); 825 + headers.add("Content-Type", "text/plain"); 826 + headers.add("X-Custom", "hidden"); 827 + 828 + let exposed = check_cors_response(&headers, &origin, CredentialsMode::Omit).unwrap(); 829 + assert!(exposed.contains(&"content-type".to_string())); 830 + // X-Custom is not exposed without Expose-Headers. 831 + assert!(!exposed.contains(&"x-custom".to_string())); 832 + } 833 + 834 + #[test] 835 + fn cors_response_exact_origin() { 836 + let origin = make_origin("https", "example.com", None); 837 + let mut headers = Headers::new(); 838 + headers.add("Access-Control-Allow-Origin", "https://example.com"); 839 + 840 + let exposed = check_cors_response(&headers, &origin, CredentialsMode::Omit).unwrap(); 841 + assert!(exposed.contains(&"content-type".to_string())); 842 + } 843 + 844 + #[test] 845 + fn cors_response_wrong_origin_blocked() { 846 + let origin = make_origin("https", "example.com", None); 847 + let mut headers = Headers::new(); 848 + headers.add("Access-Control-Allow-Origin", "https://other.com"); 849 + 850 + let result = check_cors_response(&headers, &origin, CredentialsMode::Omit); 851 + assert!(result.is_err()); 852 + } 853 + 854 + #[test] 855 + fn cors_response_no_header_blocked() { 856 + let origin = make_origin("https", "example.com", None); 857 + let headers = Headers::new(); 858 + 859 + let result = check_cors_response(&headers, &origin, CredentialsMode::Omit); 860 + assert!(result.is_err()); 861 + } 862 + 863 + #[test] 864 + fn cors_response_expose_headers() { 865 + let origin = make_origin("https", "example.com", None); 866 + let mut headers = Headers::new(); 867 + headers.add("Access-Control-Allow-Origin", "*"); 868 + headers.add("Access-Control-Expose-Headers", "X-Custom, X-Request-Id"); 869 + headers.add("X-Custom", "visible"); 870 + headers.add("X-Request-Id", "abc"); 871 + headers.add("X-Secret", "hidden"); 872 + 873 + let exposed = check_cors_response(&headers, &origin, CredentialsMode::Omit).unwrap(); 874 + assert!(exposed.contains(&"x-custom".to_string())); 875 + assert!(exposed.contains(&"x-request-id".to_string())); 876 + assert!(!exposed.contains(&"x-secret".to_string())); 877 + } 878 + 879 + #[test] 880 + fn cors_response_expose_wildcard() { 881 + let origin = make_origin("https", "example.com", None); 882 + let mut headers = Headers::new(); 883 + headers.add("Access-Control-Allow-Origin", "*"); 884 + headers.add("Access-Control-Expose-Headers", "*"); 885 + headers.add("X-Custom", "visible"); 886 + 887 + let exposed = check_cors_response(&headers, &origin, CredentialsMode::Omit).unwrap(); 888 + assert!(exposed.contains(&"x-custom".to_string())); 889 + } 890 + 891 + #[test] 892 + fn cors_response_credentials_wildcard_rejected() { 893 + let origin = make_origin("https", "example.com", None); 894 + let mut headers = Headers::new(); 895 + headers.add("Access-Control-Allow-Origin", "*"); 896 + headers.add("Access-Control-Allow-Credentials", "true"); 897 + 898 + let result = check_cors_response(&headers, &origin, CredentialsMode::Include); 899 + assert!(result.is_err()); 900 + } 901 + 902 + #[test] 903 + fn cors_response_credentials_ok() { 904 + let origin = make_origin("https", "example.com", None); 905 + let mut headers = Headers::new(); 906 + headers.add("Access-Control-Allow-Origin", "https://example.com"); 907 + headers.add("Access-Control-Allow-Credentials", "true"); 908 + 909 + let exposed = check_cors_response(&headers, &origin, CredentialsMode::Include).unwrap(); 910 + assert!(exposed.contains(&"content-type".to_string())); 911 + } 912 + 913 + // -- filter_response_headers -- 914 + 915 + #[test] 916 + fn filter_headers_basic() { 917 + let mut headers = Headers::new(); 918 + headers.add("Content-Type", "text/plain"); 919 + headers.add("X-Custom", "value"); 920 + headers.add("X-Secret", "hidden"); 921 + 922 + let exposed = vec!["content-type".to_string(), "x-custom".to_string()]; 923 + let filtered = filter_response_headers(&headers, &exposed); 924 + assert!(filtered.get("Content-Type").is_some()); 925 + assert!(filtered.get("X-Custom").is_some()); 926 + assert!(filtered.get("X-Secret").is_none()); 927 + } 928 + 929 + // -- PreflightCache -- 930 + 931 + #[test] 932 + fn cache_store_and_lookup() { 933 + let origin = make_origin("https", "example.com", None); 934 + let mut cache = PreflightCache::new(); 935 + 936 + let result = PreflightResult { 937 + allowed_methods: vec!["PUT".to_string()], 938 + allowed_headers: vec!["x-custom".to_string()], 939 + max_age: 600, 940 + }; 941 + cache.store(&origin, "https://api.example.com/data", &result); 942 + 943 + let mut headers = Headers::new(); 944 + headers.add("X-Custom", "value"); 945 + 946 + assert!(cache.lookup( 947 + &origin, 948 + "https://api.example.com/data", 949 + Method::Put, 950 + &headers, 951 + )); 952 + } 953 + 954 + #[test] 955 + fn cache_miss_different_url() { 956 + let origin = make_origin("https", "example.com", None); 957 + let mut cache = PreflightCache::new(); 958 + 959 + let result = PreflightResult { 960 + allowed_methods: vec!["PUT".to_string()], 961 + allowed_headers: vec![], 962 + max_age: 600, 963 + }; 964 + cache.store(&origin, "https://api.example.com/a", &result); 965 + 966 + assert!(!cache.lookup( 967 + &origin, 968 + "https://api.example.com/b", 969 + Method::Put, 970 + &Headers::new(), 971 + )); 972 + } 973 + 974 + #[test] 975 + fn cache_miss_different_origin() { 976 + let origin_a = make_origin("https", "a.com", None); 977 + let origin_b = make_origin("https", "b.com", None); 978 + let mut cache = PreflightCache::new(); 979 + 980 + let result = PreflightResult { 981 + allowed_methods: vec!["PUT".to_string()], 982 + allowed_headers: vec![], 983 + max_age: 600, 984 + }; 985 + cache.store(&origin_a, "https://api.example.com/data", &result); 986 + 987 + assert!(!cache.lookup( 988 + &origin_b, 989 + "https://api.example.com/data", 990 + Method::Put, 991 + &Headers::new(), 992 + )); 993 + } 994 + 995 + #[test] 996 + fn cache_miss_disallowed_method() { 997 + let origin = make_origin("https", "example.com", None); 998 + let mut cache = PreflightCache::new(); 999 + 1000 + let result = PreflightResult { 1001 + allowed_methods: vec!["PUT".to_string()], 1002 + allowed_headers: vec![], 1003 + max_age: 600, 1004 + }; 1005 + cache.store(&origin, "https://api.example.com/data", &result); 1006 + 1007 + assert!(!cache.lookup( 1008 + &origin, 1009 + "https://api.example.com/data", 1010 + Method::Delete, 1011 + &Headers::new(), 1012 + )); 1013 + } 1014 + 1015 + #[test] 1016 + fn cache_miss_disallowed_header() { 1017 + let origin = make_origin("https", "example.com", None); 1018 + let mut cache = PreflightCache::new(); 1019 + 1020 + let result = PreflightResult { 1021 + allowed_methods: vec!["PUT".to_string()], 1022 + allowed_headers: vec!["x-allowed".to_string()], 1023 + max_age: 600, 1024 + }; 1025 + cache.store(&origin, "https://api.example.com/data", &result); 1026 + 1027 + let mut headers = Headers::new(); 1028 + headers.add("X-Disallowed", "value"); 1029 + 1030 + assert!(!cache.lookup( 1031 + &origin, 1032 + "https://api.example.com/data", 1033 + Method::Put, 1034 + &headers, 1035 + )); 1036 + } 1037 + 1038 + #[test] 1039 + fn cache_allows_simple_method_without_listing() { 1040 + let origin = make_origin("https", "example.com", None); 1041 + let mut cache = PreflightCache::new(); 1042 + 1043 + let result = PreflightResult { 1044 + allowed_methods: vec![], 1045 + allowed_headers: vec!["x-custom".to_string()], 1046 + max_age: 600, 1047 + }; 1048 + cache.store(&origin, "https://api.example.com/data", &result); 1049 + 1050 + let mut headers = Headers::new(); 1051 + headers.add("X-Custom", "value"); 1052 + 1053 + // GET is a simple method, should be allowed even without listing. 1054 + assert!(cache.lookup( 1055 + &origin, 1056 + "https://api.example.com/data", 1057 + Method::Get, 1058 + &headers, 1059 + )); 1060 + } 1061 + 1062 + #[test] 1063 + fn cache_evict_expired() { 1064 + let origin = make_origin("https", "example.com", None); 1065 + let mut cache = PreflightCache::new(); 1066 + 1067 + // Store with max_age=0 so it's immediately expired. 1068 + let result = PreflightResult { 1069 + allowed_methods: vec!["PUT".to_string()], 1070 + allowed_headers: vec![], 1071 + max_age: 0, 1072 + }; 1073 + cache.store(&origin, "https://api.example.com/data", &result); 1074 + 1075 + // Wait a tiny bit to ensure expiration. 1076 + std::thread::sleep(std::time::Duration::from_millis(10)); 1077 + 1078 + cache.evict_expired(); 1079 + assert!(cache.entries.is_empty()); 1080 + } 1081 + 1082 + #[test] 1083 + fn default_cache_is_empty() { 1084 + let cache = PreflightCache::default(); 1085 + assert!(cache.entries.is_empty()); 1086 + } 1087 + 1088 + // -- CORS-safelisted content type checks -- 1089 + 1090 + #[test] 1091 + fn form_urlencoded_is_safelisted() { 1092 + assert!(is_safelisted_content_type( 1093 + "application/x-www-form-urlencoded" 1094 + )); 1095 + } 1096 + 1097 + #[test] 1098 + fn multipart_is_safelisted() { 1099 + assert!(is_safelisted_content_type("multipart/form-data")); 1100 + } 1101 + 1102 + #[test] 1103 + fn text_plain_is_safelisted() { 1104 + assert!(is_safelisted_content_type("text/plain")); 1105 + } 1106 + 1107 + #[test] 1108 + fn json_is_not_safelisted() { 1109 + assert!(!is_safelisted_content_type("application/json")); 1110 + } 1111 + 1112 + #[test] 1113 + fn content_type_with_params_is_safelisted() { 1114 + assert!(is_safelisted_content_type("text/plain; charset=utf-8")); 1115 + } 1116 + 1117 + // -- Safelisted response headers -- 1118 + 1119 + #[test] 1120 + fn safelisted_response_headers_always_exposed() { 1121 + let origin = make_origin("https", "example.com", None); 1122 + let mut headers = Headers::new(); 1123 + headers.add("Access-Control-Allow-Origin", "*"); 1124 + headers.add("Content-Type", "text/html"); 1125 + headers.add("Cache-Control", "max-age=3600"); 1126 + 1127 + let exposed = check_cors_response(&headers, &origin, CredentialsMode::Omit).unwrap(); 1128 + assert!(exposed.contains(&"content-type".to_string())); 1129 + assert!(exposed.contains(&"cache-control".to_string())); 1130 + assert!(exposed.contains(&"content-length".to_string())); 1131 + assert!(exposed.contains(&"expires".to_string())); 1132 + assert!(exposed.contains(&"last-modified".to_string())); 1133 + assert!(exposed.contains(&"pragma".to_string())); 1134 + } 1135 + }
+2 -1
crates/net/src/lib.rs
··· 1 - //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2. 1 + //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2, CORS. 2 2 3 3 pub mod client; 4 + pub mod cors; 4 5 pub mod dns; 5 6 pub mod http; 6 7 pub mod tcp;