Skip to main content

gosub_sonar/net/
cors.rs

1//! CORS — Cross-Origin Resource Sharing ([Fetch] §3.2, §4.9–4.10).
2//!
3//! This module holds the pure spec logic: the safelist predicates, the *CORS check* run against
4//! response headers, preflight response validation, and the `Access-Control-Expose-Headers`
5//! response filter. The fetcher wires these into its redirect loop; nothing here performs I/O.
6//!
7//! The crate owns the mechanism — the CORS check must run on every redirect hop's response,
8//! and only the fetcher sees intermediate hops. The embedder owns the policy through the
9//! request fields that feed these checks:
10//!
11//! - No [`FetchRequest::origin`](crate::FetchRequest::origin) → CORS is entirely inert, like
12//!   mixed content.
13//! - [`RequestMode`](crate::RequestMode) selects the regime: `SameOrigin` refuses cross-origin
14//!   targets, `NoCors` restricts methods/headers and yields an [opaque](ResponseTainting::Opaque)
15//!   response, `Cors` runs the full check + preflight. `Navigate` and `Websocket` are exempt
16//!   (navigations are not CORS-checked; a WebSocket server opts in via its own handshake).
17//! - [`RequestCredentials`](crate::net::types::RequestCredentials) decides whether cookies ride
18//!   along and how strict the allow-origin match must be.
19//!
20//! Enforcement fails requests the spec says must fail; it never hides data from the embedder.
21//! What a script may read from a response that survived is described by [`ResponseTainting`] on
22//! [`FetchResultMeta`](crate::FetchResultMeta) plus [`readable_headers`] — enforcing that
23//! visibility boundary (and body-sniffing policies like ORB on top of it) is the embedder's job.
24//!
25//! Native-only: on wasm32 the browser's `fetch()` enforces CORS itself and does not expose the
26//! `Access-Control-*` headers these checks would need, so the fetcher skips them there.
27//!
28//! [Fetch]: https://fetch.spec.whatwg.org/
29
30#[cfg(not(target_arch = "wasm32"))]
31use chrono::{DateTime, Utc};
32use http::header::{self, HeaderMap, HeaderName};
33use http::Method;
34use std::fmt::Display;
35#[cfg(not(target_arch = "wasm32"))]
36use std::time::Duration;
37#[cfg(not(target_arch = "wasm32"))]
38use url::Url;
39
40/// Why a request failed CORS. Carried by
41/// [`BlockReason::Cors`](crate::net::types::BlockReason::Cors).
42#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
43pub enum CorsError {
44    /// The response carried no `Access-Control-Allow-Origin` header.
45    MissingAllowOrigin,
46    /// `Access-Control-Allow-Origin` did not match the request's origin (or the header
47    /// appeared more than once, which can never match).
48    OriginMismatch,
49    /// `Access-Control-Allow-Origin: *` cannot authorize a credentialed request.
50    WildcardWithCredentials,
51    /// The request carried credentials but `Access-Control-Allow-Credentials: true` was absent.
52    CredentialsNotAllowed,
53    /// The request's mode is [`SameOrigin`](crate::RequestMode::SameOrigin) but a hop targeted
54    /// another origin.
55    SameOriginMode,
56    /// A cross-origin [`NoCors`](crate::RequestMode::NoCors) request used a method other than
57    /// GET, HEAD, or POST.
58    UnsafeMethodForNoCors,
59    /// A cross-origin [`NoCors`](crate::RequestMode::NoCors) request carried a header that is
60    /// neither CORS-safelisted nor set by the fetcher itself.
61    UnsafeHeaderForNoCors,
62    /// The preflight response status was not in the 2xx range.
63    PreflightStatus,
64    /// `Access-Control-Allow-Methods` or `Access-Control-Allow-Headers` could not be parsed.
65    PreflightInvalidResponse,
66    /// The preflight response did not allow the request's method.
67    PreflightMethodRejected,
68    /// The preflight response did not allow one of the request's non-safelisted headers.
69    PreflightHeaderRejected,
70    /// A redirect `Location` carried embedded `user:password` credentials — refused in cors
71    /// mode, and cross-origin in any mode.
72    CredentialedRedirect,
73}
74
75impl Display for CorsError {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        let s = match self {
78            Self::MissingAllowOrigin => "no Access-Control-Allow-Origin header",
79            Self::OriginMismatch => "Access-Control-Allow-Origin does not match the origin",
80            Self::WildcardWithCredentials => {
81                "Access-Control-Allow-Origin '*' cannot authorize a credentialed request"
82            }
83            Self::CredentialsNotAllowed => "Access-Control-Allow-Credentials is not 'true'",
84            Self::SameOriginMode => "same-origin mode request targeted another origin",
85            Self::UnsafeMethodForNoCors => "method not allowed for a cross-origin no-cors request",
86            Self::UnsafeHeaderForNoCors => "header not allowed for a cross-origin no-cors request",
87            Self::PreflightStatus => "preflight response status was not ok",
88            Self::PreflightInvalidResponse => "preflight response headers could not be parsed",
89            Self::PreflightMethodRejected => "method not allowed by preflight response",
90            Self::PreflightHeaderRejected => "header not allowed by preflight response",
91            Self::CredentialedRedirect => "redirect URL with embedded credentials",
92        };
93        f.write_str(s)
94    }
95}
96
97/// How much of a response the initiating document's scripts may read ([Fetch] §2.2.5,
98/// *response tainting*).
99///
100/// The fetcher only *annotates* — the full response is always handed to the embedder, because
101/// the embedder itself must be able to render an opaque `<img>` or feed a body sniffer. The
102/// embedder enforces the visibility boundary using this value, typically via
103/// [`FetchResultMeta::readable_headers`](crate::FetchResultMeta::readable_headers).
104///
105/// [Fetch]: https://fetch.spec.whatwg.org/#concept-request-response-tainting
106#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
107pub enum ResponseTainting {
108    /// Same-origin (or no document context): everything is readable except `Set-Cookie`.
109    #[default]
110    Basic,
111    /// A cross-origin CORS response: readable up to the CORS-safelisted response headers plus
112    /// whatever `Access-Control-Expose-Headers` names.
113    Cors,
114    /// A cross-origin `no-cors` response: scripts may observe that it exists, nothing more.
115    Opaque,
116}
117
118/// The `Origin` serialization used in CORS comparisons: the ASCII serialization of the origin,
119/// or the literal `null` once the redirect chain has tainted it (an opaque origin also
120/// serializes as `null`).
121#[cfg(not(target_arch = "wasm32"))]
122pub(crate) fn serialize_origin(origin: &url::Origin, tainted: bool) -> String {
123    if tainted {
124        "null".to_string()
125    } else {
126        origin.ascii_serialization()
127    }
128}
129
130/// CORS-safelisted method ([Fetch] §2.2.1): may go cross-origin without a preflight.
131///
132/// [Fetch]: https://fetch.spec.whatwg.org/#cors-safelisted-method
133pub fn is_cors_safelisted_method(method: &Method) -> bool {
134    matches!(*method, Method::GET | Method::HEAD | Method::POST)
135}
136
137/// A CORS-unsafe request-header byte ([Fetch] §2.2.2).
138fn is_cors_unsafe_header_byte(b: u8) -> bool {
139    matches!(b,
140        0x00..=0x08 | 0x0A..=0x1F | 0x22 | 0x28 | 0x29 | 0x3A | 0x3C |
141        0x3E | 0x3F | 0x40 | 0x5B..=0x5D | 0x7B | 0x7D | 0x7F)
142}
143
144/// CORS-safelisted request header ([Fetch] §2.2.2): a name/value pair that may go cross-origin
145/// without being listed in a preflight response.
146///
147/// [Fetch]: https://fetch.spec.whatwg.org/#cors-safelisted-request-header
148pub fn is_cors_safelisted_request_header(name: &HeaderName, value: &[u8]) -> bool {
149    if value.len() > 128 {
150        return false;
151    }
152    match name.as_str() {
153        "accept" => !value.iter().copied().any(is_cors_unsafe_header_byte),
154        "accept-language" | "content-language" => value.iter().all(|b| {
155            matches!(b,
156                0x30..=0x39 | 0x41..=0x5A | 0x61..=0x7A |
157                0x20 | 0x2A | 0x2C | 0x2D | 0x2E | 0x3B | 0x3D)
158        }),
159        "content-type" => {
160            if value.iter().copied().any(is_cors_unsafe_header_byte) {
161                return false;
162            }
163            let Ok(s) = std::str::from_utf8(value) else {
164                return false;
165            };
166            let essence = s
167                .split(';')
168                .next()
169                .unwrap_or("")
170                .trim_matches([' ', '\t'])
171                .to_ascii_lowercase();
172            matches!(
173                essence.as_str(),
174                "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain"
175            )
176        }
177        // A single `bytes=m-n` / `bytes=m-` range (media loads); `bytes=-m` is not safelisted.
178        "range" => {
179            let Some(rest) = value.strip_prefix(b"bytes=") else {
180                return false;
181            };
182            let Ok(rest) = std::str::from_utf8(rest) else {
183                return false;
184            };
185            match rest.split_once('-') {
186                Some((start, end)) => {
187                    !start.is_empty()
188                        && start.bytes().all(|b| b.is_ascii_digit())
189                        && end.bytes().all(|b| b.is_ascii_digit())
190                }
191                None => false,
192            }
193        }
194        _ => false,
195    }
196}
197
198/// Forbidden request header ([Fetch] §2.2.2): controlled by the user agent — here, by the
199/// fetcher itself (`Referer`, `Origin`, `Cookie`, `Sec-Fetch-*`, …). These never count toward
200/// the preflight decision: a browser sets them on cross-origin requests without asking either.
201///
202/// [Fetch]: https://fetch.spec.whatwg.org/#forbidden-request-header
203pub fn is_forbidden_request_header(name: &HeaderName) -> bool {
204    let n = name.as_str();
205    n.starts_with("proxy-")
206        || n.starts_with("sec-")
207        || matches!(
208            n,
209            "accept-charset"
210                | "accept-encoding"
211                | "access-control-request-headers"
212                | "access-control-request-method"
213                | "connection"
214                | "content-length"
215                | "cookie"
216                | "cookie2"
217                | "date"
218                | "dnt"
219                | "expect"
220                | "host"
221                | "keep-alive"
222                | "origin"
223                | "referer"
224                | "set-cookie"
225                | "te"
226                | "trailer"
227                | "transfer-encoding"
228                | "upgrade"
229                | "via"
230        )
231}
232
233/// The caller-set header names that a preflight must get approved: not forbidden (the fetcher
234/// owns those) and not CORS-safelisted for the value they carry. Lowercase, sorted, deduplicated
235/// — the exact list sent in `Access-Control-Request-Headers`.
236pub(crate) fn unsafe_request_header_names(headers: &HeaderMap) -> Vec<String> {
237    let mut names: Vec<String> = headers
238        .iter()
239        .filter(|(name, value)| {
240            !is_forbidden_request_header(name)
241                && !is_cors_safelisted_request_header(name, value.as_bytes())
242        })
243        .map(|(name, _)| name.as_str().to_string())
244        .collect();
245    names.sort_unstable();
246    names.dedup();
247    names
248}
249
250/// Whether a cross-origin CORS-mode request with this method and these headers needs a
251/// preflight before it may be sent.
252pub fn preflight_needed(method: &Method, headers: &HeaderMap) -> bool {
253    !is_cors_safelisted_method(method) || !unsafe_request_header_names(headers).is_empty()
254}
255
256/// The *CORS check* ([Fetch] §4.10.3), run against every response — including each redirect
257/// hop's — once the request has left its origin in `cors` mode.
258///
259/// `credentials_include` is the request's credentials **mode**, not whether cookies were
260/// actually attached on this hop: the spec keys the wildcard and allow-credentials rules on the
261/// mode alone.
262///
263/// [Fetch]: https://fetch.spec.whatwg.org/#concept-cors-check
264#[cfg(not(target_arch = "wasm32"))]
265pub(crate) fn cors_check(
266    origin: &url::Origin,
267    tainted: bool,
268    credentials_include: bool,
269    response: &HeaderMap,
270) -> Result<(), CorsError> {
271    let mut values = response.get_all(header::ACCESS_CONTROL_ALLOW_ORIGIN).iter();
272    let Some(allow) = values.next() else {
273        return Err(CorsError::MissingAllowOrigin);
274    };
275    // A duplicated header would compare as the joined list, which can never match an origin.
276    if values.next().is_some() {
277        return Err(CorsError::OriginMismatch);
278    }
279    if allow.as_bytes() == b"*" {
280        return if credentials_include {
281            Err(CorsError::WildcardWithCredentials)
282        } else {
283            Ok(())
284        };
285    }
286    if allow.as_bytes() != serialize_origin(origin, tainted).as_bytes() {
287        return Err(CorsError::OriginMismatch);
288    }
289    if !credentials_include {
290        return Ok(());
291    }
292    match response.get(header::ACCESS_CONTROL_ALLOW_CREDENTIALS) {
293        Some(v) if v.as_bytes() == b"true" => Ok(()),
294        _ => Err(CorsError::CredentialsNotAllowed),
295    }
296}
297
298/// What a preflight response allowed, plus for how long it may be cached.
299///
300/// Produced by validating a preflight response; consulted (directly, or later out of a
301/// [`CorsPreflightCache`]) via [`permits`](Self::permits).
302#[cfg(not(target_arch = "wasm32"))]
303#[derive(Debug, Clone)]
304pub struct PreflightAllows {
305    methods: Vec<String>,
306    methods_wildcard: bool,
307    headers: Vec<String>,
308    headers_wildcard: bool,
309    /// How long this entry may be cached: `Access-Control-Max-Age`, defaulted and capped.
310    pub max_age: Duration,
311}
312
313/// `Access-Control-Max-Age` when the server sends none ([Fetch] §4.9: 5 seconds).
314///
315/// [Fetch]: https://fetch.spec.whatwg.org/
316#[cfg(not(target_arch = "wasm32"))]
317pub const DEFAULT_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(5);
318/// Upper bound on `Access-Control-Max-Age`, matching Chromium's two-hour cap, so a
319/// misconfigured server cannot pin a stale grant for days.
320#[cfg(not(target_arch = "wasm32"))]
321pub const MAX_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(2 * 60 * 60);
322
323#[cfg(not(target_arch = "wasm32"))]
324impl PreflightAllows {
325    /// Whether these grants cover a request: its method and every one of its non-safelisted
326    /// header names ([Fetch] §4.9 steps 7.5–7.7). The `*` wildcard only counts for a
327    /// credential-less request, and `Authorization` must always be listed explicitly.
328    ///
329    /// [Fetch]: https://fetch.spec.whatwg.org/
330    pub fn permits(
331        &self,
332        method: &Method,
333        unsafe_header_names: &[String],
334        credentials_include: bool,
335    ) -> Result<(), CorsError> {
336        let allowed = self.methods.iter().any(|m| m == method.as_str())
337            || (self.methods_wildcard && !credentials_include)
338            || is_cors_safelisted_method(method);
339        if !allowed {
340            return Err(CorsError::PreflightMethodRejected);
341        }
342        for name in unsafe_header_names {
343            let listed = self.headers.iter().any(|h| h == name);
344            let wildcard_ok =
345                self.headers_wildcard && !credentials_include && name != "authorization";
346            if !listed && !wildcard_ok {
347                return Err(CorsError::PreflightHeaderRejected);
348            }
349        }
350        Ok(())
351    }
352}
353
354/// Parse one `Access-Control-Allow-{Methods,Headers}` header list: comma-separated tokens
355/// across any number of field lines. Returns the tokens (lowercased when `lowercase`) and
356/// whether `*` was among them; a non-token member fails the whole parse, as the spec demands.
357fn parse_token_list(
358    response: &HeaderMap,
359    name: HeaderName,
360    lowercase: bool,
361) -> Result<(Vec<String>, bool), CorsError> {
362    let mut items = Vec::new();
363    let mut wildcard = false;
364    for value in response.get_all(&name) {
365        let s = value
366            .to_str()
367            .map_err(|_| CorsError::PreflightInvalidResponse)?;
368        for item in s.split(',') {
369            let item = item.trim_matches([' ', '\t']);
370            if item.is_empty() {
371                continue;
372            }
373            if item == "*" {
374                wildcard = true;
375                continue;
376            }
377            let is_token = item.bytes().all(|b| {
378                b.is_ascii_alphanumeric()
379                    || matches!(
380                        b,
381                        b'!' | b'#'
382                            | b'$'
383                            | b'%'
384                            | b'&'
385                            | b'\''
386                            | b'*'
387                            | b'+'
388                            | b'-'
389                            | b'.'
390                            | b'^'
391                            | b'_'
392                            | b'`'
393                            | b'|'
394                            | b'~'
395                    )
396            });
397            if !is_token {
398                return Err(CorsError::PreflightInvalidResponse);
399            }
400            items.push(if lowercase {
401                item.to_ascii_lowercase()
402            } else {
403                item.to_string()
404            });
405        }
406    }
407    Ok((items, wildcard))
408}
409
410/// Validate a preflight response ([Fetch] §4.9 steps 6–7): ok status, the CORS check, then the
411/// allow lists. The caller still has to test the actual request against the result with
412/// [`PreflightAllows::permits`].
413#[cfg(not(target_arch = "wasm32"))]
414pub(crate) fn validate_preflight_response(
415    status: u16,
416    response: &HeaderMap,
417    origin: &url::Origin,
418    tainted: bool,
419    credentials_include: bool,
420) -> Result<PreflightAllows, CorsError> {
421    if !(200..300).contains(&status) {
422        return Err(CorsError::PreflightStatus);
423    }
424    cors_check(origin, tainted, credentials_include, response)?;
425    let (methods, methods_wildcard) =
426        parse_token_list(response, header::ACCESS_CONTROL_ALLOW_METHODS, false)?;
427    let (headers, headers_wildcard) =
428        parse_token_list(response, header::ACCESS_CONTROL_ALLOW_HEADERS, true)?;
429    let max_age = response
430        .get(header::ACCESS_CONTROL_MAX_AGE)
431        .and_then(|v| v.to_str().ok())
432        .and_then(|s| s.trim().parse::<u64>().ok())
433        .map_or(DEFAULT_PREFLIGHT_MAX_AGE, Duration::from_secs)
434        .min(MAX_PREFLIGHT_MAX_AGE);
435    Ok(PreflightAllows {
436        methods,
437        methods_wildcard,
438        headers,
439        headers_wildcard,
440        max_age,
441    })
442}
443
444/// The request headers a preflight `OPTIONS` carries ([Fetch] §4.9 steps 1–2). The fetcher
445/// adds `Origin` and the `Sec-Fetch-*` set on top through its normal per-hop machinery.
446#[cfg(not(target_arch = "wasm32"))]
447pub(crate) fn preflight_request_headers(
448    method: &Method,
449    unsafe_header_names: &[String],
450) -> HeaderMap {
451    let mut headers = HeaderMap::new();
452    headers.insert(header::ACCEPT, http::HeaderValue::from_static("*/*"));
453    if let Ok(v) = method.as_str().parse() {
454        headers.insert(header::ACCESS_CONTROL_REQUEST_METHOD, v);
455    }
456    if !unsafe_header_names.is_empty() {
457        if let Ok(v) = unsafe_header_names.join(",").parse() {
458            headers.insert(header::ACCESS_CONTROL_REQUEST_HEADERS, v);
459        }
460    }
461    headers
462}
463
464/// CORS-safelisted response headers ([Fetch] §2.2.3): always readable from a `cors`-tainted
465/// response, even without `Access-Control-Expose-Headers`.
466const CORS_SAFELISTED_RESPONSE_HEADERS: [&str; 7] = [
467    "cache-control",
468    "content-language",
469    "content-length",
470    "content-type",
471    "expires",
472    "last-modified",
473    "pragma",
474];
475
476/// The header view scripts may read, given a response's tainting ([Fetch] §4.10.2, *filtered
477/// response*). The full header map stays on the metadata — this is the embedder-facing filter,
478/// not a mutation.
479///
480/// `Set-Cookie` is never readable. An unparseable `Access-Control-Expose-Headers` exposes
481/// nothing beyond the safelist; its `*` wildcard only counts for credential-less requests.
482///
483/// [Fetch]: https://fetch.spec.whatwg.org/
484pub fn readable_headers(
485    tainting: ResponseTainting,
486    headers: &HeaderMap,
487    credentials_include: bool,
488) -> HeaderMap {
489    let keep_all_but_cookies = |headers: &HeaderMap| {
490        let mut out = HeaderMap::new();
491        for (name, value) in headers {
492            if name != header::SET_COOKIE && name.as_str() != "set-cookie2" {
493                out.append(name.clone(), value.clone());
494            }
495        }
496        out
497    };
498    match tainting {
499        ResponseTainting::Basic => keep_all_but_cookies(headers),
500        ResponseTainting::Opaque => HeaderMap::new(),
501        ResponseTainting::Cors => {
502            let (exposed, wildcard) =
503                parse_token_list(headers, header::ACCESS_CONTROL_EXPOSE_HEADERS, true)
504                    .unwrap_or((Vec::new(), false));
505            if wildcard && !credentials_include {
506                return keep_all_but_cookies(headers);
507            }
508            let mut out = HeaderMap::new();
509            for (name, value) in headers {
510                let n = name.as_str();
511                if CORS_SAFELISTED_RESPONSE_HEADERS.contains(&n)
512                    || (exposed.iter().any(|e| e == n) && n != "set-cookie" && n != "set-cookie2")
513                {
514                    out.append(name.clone(), value.clone());
515                }
516            }
517            out
518        }
519    }
520}
521
522/// Cache of preflight grants, keyed per (serialized origin, URL, credentials flag) — the
523/// [Fetch] §4.9.1 *CORS-preflight cache*, collapsed to one entry per key like browsers do.
524///
525/// The crate owns the protocol (validation, `Access-Control-Max-Age`, expiry); an
526/// implementation only has to behave like a map. The default is [`InMemoryPreflightCache`];
527/// supply your own to share or inspect grants, or set the config field to `None` to preflight
528/// every time.
529///
530/// [Fetch]: https://fetch.spec.whatwg.org/
531#[cfg(not(target_arch = "wasm32"))]
532pub trait CorsPreflightCache: Send + Sync {
533    /// Look up an unexpired grant. `now` is the fetcher's clock; return `None` for entries
534    /// that have expired.
535    fn get(
536        &self,
537        origin: &str,
538        url: &Url,
539        credentials: bool,
540        now: DateTime<Utc>,
541    ) -> Option<PreflightAllows>;
542
543    /// Store a grant. `allows.max_age` is already defaulted and capped; the entry expires at
544    /// `now + allows.max_age`, replacing any previous entry for the key.
545    fn put(
546        &self,
547        origin: &str,
548        url: &Url,
549        credentials: bool,
550        allows: PreflightAllows,
551        now: DateTime<Utc>,
552    );
553}
554
555/// In-process [`CorsPreflightCache`] with no persistence; expired entries are pruned on
556/// insertion.
557#[cfg(not(target_arch = "wasm32"))]
558#[derive(Default)]
559pub struct InMemoryPreflightCache {
560    entries: parking_lot::RwLock<std::collections::HashMap<PreflightKey, PreflightEntry>>,
561}
562
563/// (serialized origin, URL without fragment, credentials flag).
564#[cfg(not(target_arch = "wasm32"))]
565type PreflightKey = (String, String, bool);
566/// A grant and the instant it expires.
567#[cfg(not(target_arch = "wasm32"))]
568type PreflightEntry = (PreflightAllows, DateTime<Utc>);
569
570#[cfg(not(target_arch = "wasm32"))]
571impl InMemoryPreflightCache {
572    /// An empty cache.
573    pub fn new() -> Self {
574        Self::default()
575    }
576
577    /// Fragments never reach the server, so they must not split cache entries.
578    fn key(origin: &str, url: &Url, credentials: bool) -> PreflightKey {
579        let mut url = url.clone();
580        url.set_fragment(None);
581        (origin.to_string(), url.to_string(), credentials)
582    }
583}
584
585#[cfg(not(target_arch = "wasm32"))]
586impl CorsPreflightCache for InMemoryPreflightCache {
587    fn get(
588        &self,
589        origin: &str,
590        url: &Url,
591        credentials: bool,
592        now: DateTime<Utc>,
593    ) -> Option<PreflightAllows> {
594        let entries = self.entries.read();
595        let (allows, expires) = entries.get(&Self::key(origin, url, credentials))?;
596        (*expires > now).then(|| allows.clone())
597    }
598
599    fn put(
600        &self,
601        origin: &str,
602        url: &Url,
603        credentials: bool,
604        allows: PreflightAllows,
605        now: DateTime<Utc>,
606    ) {
607        // `max_age` is capped at MAX_PREFLIGHT_MAX_AGE, so the conversion cannot overflow;
608        // zero (an already-expired entry) is the safe direction if that ever changes.
609        let expires =
610            now + chrono::TimeDelta::from_std(allows.max_age).unwrap_or(chrono::TimeDelta::zero());
611        let mut entries = self.entries.write();
612        entries.retain(|_, (_, exp)| *exp > now);
613        entries.insert(Self::key(origin, url, credentials), (allows, expires));
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use http::HeaderValue;
621
622    fn origin(s: &str) -> url::Origin {
623        Url::parse(s).unwrap().origin()
624    }
625
626    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
627        let mut h = HeaderMap::new();
628        for (name, value) in pairs {
629            h.append(
630                HeaderName::from_bytes(name.as_bytes()).unwrap(),
631                HeaderValue::from_str(value).unwrap(),
632            );
633        }
634        h
635    }
636
637    // --- safelisted methods / headers ---
638
639    #[test]
640    fn safelisted_methods() {
641        assert!(is_cors_safelisted_method(&Method::GET));
642        assert!(is_cors_safelisted_method(&Method::HEAD));
643        assert!(is_cors_safelisted_method(&Method::POST));
644        assert!(!is_cors_safelisted_method(&Method::PUT));
645        assert!(!is_cors_safelisted_method(&Method::DELETE));
646        assert!(!is_cors_safelisted_method(&Method::PATCH));
647    }
648
649    #[test]
650    fn safelisted_headers_by_name_and_value() {
651        let n = |s: &str| HeaderName::from_bytes(s.as_bytes()).unwrap();
652        assert!(is_cors_safelisted_request_header(
653            &n("accept"),
654            b"text/html,application/xhtml+xml;q=0.9,*/*;q=0.8"
655        ));
656        // A CORS-unsafe byte in Accept.
657        assert!(!is_cors_safelisted_request_header(&n("accept"), b"a\"b"));
658        assert!(is_cors_safelisted_request_header(
659            &n("accept-language"),
660            b"en-US,en;q=0.9"
661        ));
662        // Slash is outside accept-language's byte allowlist.
663        assert!(!is_cors_safelisted_request_header(
664            &n("accept-language"),
665            b"en/US"
666        ));
667        assert!(is_cors_safelisted_request_header(
668            &n("content-type"),
669            b"text/plain;charset=UTF-8"
670        ));
671        assert!(is_cors_safelisted_request_header(
672            &n("content-type"),
673            b"MULTIPART/FORM-DATA; boundary=x"
674        ));
675        assert!(!is_cors_safelisted_request_header(
676            &n("content-type"),
677            b"application/json"
678        ));
679        assert!(is_cors_safelisted_request_header(&n("range"), b"bytes=0-"));
680        assert!(is_cors_safelisted_request_header(
681            &n("range"),
682            b"bytes=200-1000"
683        ));
684        assert!(!is_cors_safelisted_request_header(
685            &n("range"),
686            b"bytes=-500"
687        ));
688        assert!(!is_cors_safelisted_request_header(
689            &n("range"),
690            b"bytes=0-50,100-150"
691        ));
692        assert!(!is_cors_safelisted_request_header(&n("x-custom"), b"1"));
693        // Over the 128-byte value cap.
694        let long = vec![b'a'; 129];
695        assert!(!is_cors_safelisted_request_header(&n("accept"), &long));
696    }
697
698    #[test]
699    fn unsafe_names_skip_forbidden_and_safelisted() {
700        let h = headers(&[
701            ("accept", "*/*"),
702            ("cookie", "a=1"),
703            ("referer", "https://a.example/"),
704            ("sec-fetch-mode", "cors"),
705            ("x-custom", "1"),
706            ("authorization", "Bearer t"),
707            ("content-type", "application/json"),
708        ]);
709        assert_eq!(
710            unsafe_request_header_names(&h),
711            vec!["authorization", "content-type", "x-custom"]
712        );
713    }
714
715    #[test]
716    fn preflight_needed_on_method_or_header() {
717        let plain = headers(&[("accept", "*/*")]);
718        assert!(!preflight_needed(&Method::GET, &plain));
719        assert!(!preflight_needed(&Method::POST, &plain));
720        assert!(preflight_needed(&Method::PUT, &plain));
721        assert!(preflight_needed(
722            &Method::GET,
723            &headers(&[("x-custom", "1")])
724        ));
725        assert!(preflight_needed(
726            &Method::POST,
727            &headers(&[("content-type", "application/json")])
728        ));
729    }
730
731    // --- CORS check ---
732
733    #[test]
734    fn cors_check_matches_origin() {
735        let o = origin("https://a.example");
736        let ok = headers(&[("access-control-allow-origin", "https://a.example")]);
737        assert_eq!(cors_check(&o, false, false, &ok), Ok(()));
738        assert_eq!(
739            cors_check(&o, false, false, &HeaderMap::new()),
740            Err(CorsError::MissingAllowOrigin)
741        );
742        let wrong = headers(&[("access-control-allow-origin", "https://b.example")]);
743        assert_eq!(
744            cors_check(&o, false, false, &wrong),
745            Err(CorsError::OriginMismatch)
746        );
747        // Scheme and port are part of the origin.
748        let http = headers(&[("access-control-allow-origin", "http://a.example")]);
749        assert_eq!(
750            cors_check(&o, false, false, &http),
751            Err(CorsError::OriginMismatch)
752        );
753    }
754
755    #[test]
756    fn cors_check_wildcard_only_without_credentials() {
757        let o = origin("https://a.example");
758        let star = headers(&[("access-control-allow-origin", "*")]);
759        assert_eq!(cors_check(&o, false, false, &star), Ok(()));
760        assert_eq!(
761            cors_check(&o, false, true, &star),
762            Err(CorsError::WildcardWithCredentials)
763        );
764    }
765
766    #[test]
767    fn cors_check_credentials_require_allow_credentials_true() {
768        let o = origin("https://a.example");
769        let no_cred = headers(&[("access-control-allow-origin", "https://a.example")]);
770        assert_eq!(
771            cors_check(&o, false, true, &no_cred),
772            Err(CorsError::CredentialsNotAllowed)
773        );
774        let ok = headers(&[
775            ("access-control-allow-origin", "https://a.example"),
776            ("access-control-allow-credentials", "true"),
777        ]);
778        assert_eq!(cors_check(&o, false, true, &ok), Ok(()));
779        // Exact, case-sensitive match per spec.
780        let bad_case = headers(&[
781            ("access-control-allow-origin", "https://a.example"),
782            ("access-control-allow-credentials", "True"),
783        ]);
784        assert_eq!(
785            cors_check(&o, false, true, &bad_case),
786            Err(CorsError::CredentialsNotAllowed)
787        );
788    }
789
790    #[test]
791    fn cors_check_tainted_origin_matches_null() {
792        let o = origin("https://a.example");
793        let null = headers(&[("access-control-allow-origin", "null")]);
794        assert_eq!(cors_check(&o, true, false, &null), Ok(()));
795        let real = headers(&[("access-control-allow-origin", "https://a.example")]);
796        assert_eq!(
797            cors_check(&o, true, false, &real),
798            Err(CorsError::OriginMismatch)
799        );
800    }
801
802    #[test]
803    fn cors_check_duplicate_allow_origin_fails() {
804        let mut h = headers(&[("access-control-allow-origin", "https://a.example")]);
805        h.append(
806            header::ACCESS_CONTROL_ALLOW_ORIGIN,
807            HeaderValue::from_static("https://a.example"),
808        );
809        assert_eq!(
810            cors_check(&origin("https://a.example"), false, false, &h),
811            Err(CorsError::OriginMismatch)
812        );
813    }
814
815    // --- preflight response validation ---
816
817    fn ok_preflight(extra: &[(&str, &str)]) -> HeaderMap {
818        let mut h = headers(&[("access-control-allow-origin", "https://a.example")]);
819        for (name, value) in extra {
820            h.append(
821                HeaderName::from_bytes(name.as_bytes()).unwrap(),
822                HeaderValue::from_str(value).unwrap(),
823            );
824        }
825        h
826    }
827
828    #[test]
829    fn preflight_rejects_non_ok_status() {
830        let o = origin("https://a.example");
831        assert_eq!(
832            validate_preflight_response(403, &ok_preflight(&[]), &o, false, false).unwrap_err(),
833            CorsError::PreflightStatus
834        );
835        assert_eq!(
836            validate_preflight_response(301, &ok_preflight(&[]), &o, false, false).unwrap_err(),
837            CorsError::PreflightStatus
838        );
839    }
840
841    #[test]
842    fn preflight_allows_listed_method_and_headers() {
843        let o = origin("https://a.example");
844        let resp = ok_preflight(&[
845            ("access-control-allow-methods", "PUT, DELETE"),
846            ("access-control-allow-headers", "X-Custom, Content-Type"),
847        ]);
848        let allows = validate_preflight_response(204, &resp, &o, false, false).unwrap();
849        assert_eq!(
850            allows.permits(&Method::PUT, &["x-custom".into()], false),
851            Ok(())
852        );
853        assert_eq!(
854            allows.permits(&Method::PATCH, &[], false),
855            Err(CorsError::PreflightMethodRejected)
856        );
857        assert_eq!(
858            allows.permits(&Method::PUT, &["x-other".into()], false),
859            Err(CorsError::PreflightHeaderRejected)
860        );
861        // Safelisted methods pass even when unlisted.
862        assert_eq!(allows.permits(&Method::POST, &[], false), Ok(()));
863    }
864
865    #[test]
866    fn preflight_wildcard_rules() {
867        let o = origin("https://a.example");
868        let resp = ok_preflight(&[
869            ("access-control-allow-methods", "*"),
870            ("access-control-allow-headers", "*"),
871        ]);
872        let allows = validate_preflight_response(200, &resp, &o, false, false).unwrap();
873        assert_eq!(
874            allows.permits(&Method::DELETE, &["x-custom".into()], false),
875            Ok(())
876        );
877        // The wildcard never covers Authorization.
878        assert_eq!(
879            allows.permits(&Method::GET, &["authorization".into()], false),
880            Err(CorsError::PreflightHeaderRejected)
881        );
882        // With credentials, `*` is a literal token, not a wildcard.
883        assert_eq!(
884            allows.permits(&Method::DELETE, &[], true),
885            Err(CorsError::PreflightMethodRejected)
886        );
887    }
888
889    #[test]
890    fn preflight_invalid_token_fails_parse() {
891        let o = origin("https://a.example");
892        let resp = ok_preflight(&[("access-control-allow-methods", "PUT, DEL ETE")]);
893        assert_eq!(
894            validate_preflight_response(200, &resp, &o, false, false).unwrap_err(),
895            CorsError::PreflightInvalidResponse
896        );
897    }
898
899    #[test]
900    fn preflight_max_age_defaulted_and_capped() {
901        let o = origin("https://a.example");
902        let allows =
903            validate_preflight_response(200, &ok_preflight(&[]), &o, false, false).unwrap();
904        assert_eq!(allows.max_age, DEFAULT_PREFLIGHT_MAX_AGE);
905        let resp = ok_preflight(&[("access-control-max-age", "600")]);
906        let allows = validate_preflight_response(200, &resp, &o, false, false).unwrap();
907        assert_eq!(allows.max_age, Duration::from_secs(600));
908        let resp = ok_preflight(&[("access-control-max-age", "999999999")]);
909        let allows = validate_preflight_response(200, &resp, &o, false, false).unwrap();
910        assert_eq!(allows.max_age, MAX_PREFLIGHT_MAX_AGE);
911    }
912
913    #[test]
914    fn preflight_request_headers_shape() {
915        let h = preflight_request_headers(&Method::PUT, &["x-custom".into(), "x-other".into()]);
916        assert_eq!(h.get(header::ACCEPT).unwrap(), "*/*");
917        assert_eq!(h.get(header::ACCESS_CONTROL_REQUEST_METHOD).unwrap(), "PUT");
918        assert_eq!(
919            h.get(header::ACCESS_CONTROL_REQUEST_HEADERS).unwrap(),
920            "x-custom,x-other"
921        );
922        let h = preflight_request_headers(&Method::PUT, &[]);
923        assert!(h.get(header::ACCESS_CONTROL_REQUEST_HEADERS).is_none());
924    }
925
926    // --- response filtering ---
927
928    #[test]
929    fn readable_headers_by_tainting() {
930        let resp = headers(&[
931            ("content-type", "text/html"),
932            ("x-request-id", "42"),
933            ("set-cookie", "session=s"),
934        ]);
935        let basic = readable_headers(ResponseTainting::Basic, &resp, false);
936        assert!(basic.get("content-type").is_some());
937        assert!(basic.get("x-request-id").is_some());
938        assert!(basic.get("set-cookie").is_none());
939
940        let cors = readable_headers(ResponseTainting::Cors, &resp, false);
941        assert!(cors.get("content-type").is_some());
942        assert!(cors.get("x-request-id").is_none());
943        assert!(cors.get("set-cookie").is_none());
944
945        let opaque = readable_headers(ResponseTainting::Opaque, &resp, false);
946        assert!(opaque.is_empty());
947    }
948
949    #[test]
950    fn expose_headers_extends_cors_view() {
951        let resp = headers(&[
952            ("x-request-id", "42"),
953            ("x-secret", "s"),
954            ("access-control-expose-headers", "X-Request-Id"),
955        ]);
956        let cors = readable_headers(ResponseTainting::Cors, &resp, false);
957        assert!(cors.get("x-request-id").is_some());
958        assert!(cors.get("x-secret").is_none());
959    }
960
961    #[test]
962    fn expose_headers_wildcard_only_without_credentials() {
963        let resp = headers(&[
964            ("x-request-id", "42"),
965            ("set-cookie", "session=s"),
966            ("access-control-expose-headers", "*"),
967        ]);
968        let no_creds = readable_headers(ResponseTainting::Cors, &resp, false);
969        assert!(no_creds.get("x-request-id").is_some());
970        assert!(no_creds.get("set-cookie").is_none());
971        let creds = readable_headers(ResponseTainting::Cors, &resp, true);
972        assert!(creds.get("x-request-id").is_none());
973    }
974
975    // --- preflight cache ---
976
977    #[test]
978    fn cache_roundtrip_and_expiry() {
979        let cache = InMemoryPreflightCache::new();
980        let url = Url::parse("https://api.example/data").unwrap();
981        let o = origin("https://a.example");
982        let resp = ok_preflight(&[
983            ("access-control-allow-methods", "PUT"),
984            ("access-control-max-age", "60"),
985        ]);
986        let allows = validate_preflight_response(200, &resp, &o, false, false).unwrap();
987        let now = Utc::now();
988        cache.put("https://a.example", &url, false, allows, now);
989
990        let hit = cache.get("https://a.example", &url, false, now).unwrap();
991        assert_eq!(hit.permits(&Method::PUT, &[], false), Ok(()));
992        // Distinct key dimensions miss.
993        assert!(cache.get("https://b.example", &url, false, now).is_none());
994        assert!(cache.get("https://a.example", &url, true, now).is_none());
995        // Fragments do not split entries.
996        let frag = Url::parse("https://api.example/data#frag").unwrap();
997        assert!(cache.get("https://a.example", &frag, false, now).is_some());
998        // Expired entries are not returned.
999        let later = now + chrono::TimeDelta::seconds(61);
1000        assert!(cache.get("https://a.example", &url, false, later).is_none());
1001    }
1002
1003    #[test]
1004    fn origin_serialization() {
1005        assert_eq!(
1006            serialize_origin(&origin("https://a.example"), false),
1007            "https://a.example"
1008        );
1009        assert_eq!(serialize_origin(&origin("https://a.example"), true), "null");
1010        // Ports appear, default ports do not.
1011        assert_eq!(
1012            serialize_origin(&origin("https://a.example:8443"), false),
1013            "https://a.example:8443"
1014        );
1015    }
1016}