arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! Origin policy for realtime upgrade requests (PROGRAM.md §AP2.1-8).
//!
//! WebSocket and SSE endpoints are attacker-facing. A browser's
//! same-origin policy is *enforced by the browser for the client*, not by
//! the server — a non-browser client (or a browser with a vulnerable
//! extension) can send any `Origin`. The server must therefore validate
//! the `Origin` header itself before accepting the long-lived connection.
//! This is the same posture as CSRF protection (AGENTS.md §11, the
//! arcature-auth `CsrfLayer`): same-origin is the server-verified default,
//! not an assumption.
//!
//! The policy is **explicit configuration**: the application declares the
//! set of authorized origins (its own origin, plus any explicitly-allowed
//! cross-origin deployments). Channel names never implicitly authorize
//! (PROGRAM.md §AP2.1-8); the origin policy is orthogonal to per-channel
//! authorization.
//!
//! # Default
//!
//! [`OriginPolicy::deny_all`] rejects every upgrade request — the
//! application must opt in by listing its origins. This is the safe default
//! for an attacker-facing boundary (AGENTS.md §17: no insecure default).
//! [`OriginPolicy::allow_exact`] matches a single origin byte-for-byte;
//! [`OriginPolicy::allow_set`] matches any in a list.

use crate::axum::http::HeaderValue;

/// A normalized origin value (the `Origin` request header, or the
/// `Referer` scheme+host when `Origin` is absent). Lowercased and matched
/// as a byte string. We do not parse it as a `url::Url` to avoid pulling
/// `url` onto the realtime path (it is a transitive of `lettre`, not of
/// the realtime feature); an exact byte match on a host-configured string
/// is both sufficient and safer than parsing attacker input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedOrigin(String);

impl VerifiedOrigin {
    /// Normalize an `Origin` header value to a lowercased ASCII string for
    /// comparison. Returns `None` if the value is not valid ASCII (a
    /// non-ASCII `Origin` is malformed; reject rather than guess).
    #[must_use]
    pub fn from_header(value: &HeaderValue) -> Option<Self> {
        let bytes = value.as_bytes();
        if !bytes.is_ascii() {
            return None;
        }
        // Lowercase in place via a String (the value is owned by the header).
        let lower = bytes
            .iter()
            .map(|b| b.to_ascii_lowercase())
            .collect::<Vec<u8>>();
        // Safety: we validated ASCII above, so the bytes are valid UTF-8.
        let s = String::from_utf8(lower).ok()?;
        Some(Self(s))
    }

    /// Construct a verified origin from a trusted, already-normalized
    /// string (e.g. a configuration literal). Used by tests and by the
    /// policy constructors.
    #[must_use]
    pub fn from_trusted(value: impl Into<String>) -> Self {
        Self(value.into().to_ascii_lowercase())
    }

    /// The normalized origin string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// The server-side policy for accepting a realtime upgrade based on its
/// `Origin` header. Constructed once and stored in `AppState` (cloned
/// cheaply — it is a small `Arc`).
#[derive(Debug, Clone)]
pub enum OriginPolicy {
    /// Reject every request. The safe default for an attacker-facing
    /// boundary; the application must opt in.
    DenyAll,
    /// Accept only requests whose `Origin` matches `origin` byte-for-byte
    /// (after lowercasing).
    AllowExact { origin: VerifiedOrigin },
    /// Accept requests whose `Origin` matches any in `origins`.
    AllowSet { origins: Vec<VerifiedOrigin> },
}

/// The outcome of [`OriginPolicy::authorize`]. Origin admission is a
/// binary gate — the origin is either allowed or denied — so a dedicated
/// two-variant enum is more honest than `Result<(), ()>` (AGENTS.md §18:
/// no future-proof variants; the only failure mode is "denied"). The
/// caller maps [`OriginDecision::Denied`] to
/// [`crate::realtime::error::RealtimeError::Origin`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginDecision {
    /// The request's `Origin` matches a configured origin.
    Allowed,
    /// The request's `Origin` is absent, malformed, or not configured.
    Denied,
}

impl OriginPolicy {
    /// The safe default: reject all upgrade requests.
    #[must_use]
    pub fn deny_all() -> Self {
        Self::DenyAll
    }

    /// Accept only the given exact origin.
    #[must_use]
    pub fn allow_exact(origin: VerifiedOrigin) -> Self {
        Self::AllowExact { origin }
    }

    /// Accept any of the given origins.
    #[must_use]
    pub fn allow_set(origins: Vec<VerifiedOrigin>) -> Self {
        Self::AllowSet { origins }
    }

    /// Decide whether a request is authorized. Returns
    /// [`OriginDecision::Allowed`] if the origin is allowed,
    /// [`OriginDecision::Denied`] otherwise (the caller maps `Denied` to
    /// [`crate::realtime::error::RealtimeError::Origin`]).
    ///
    /// `present` is the request's `Origin` header value, if any. A missing
    /// `Origin` is rejected by every non-`DenyAll` policy too: a browser
    /// always sends `Origin` on a WS/SSE fetch; an absent header is a
    /// non-browser or a stripped request, which the safe default rejects.
    /// An application that legitimately serves non-browser clients can
    /// build its own extractor; this policy is the browser-facing default.
    #[must_use]
    pub fn authorize(&self, present: Option<&HeaderValue>) -> OriginDecision {
        let value = match present {
            Some(v) => v,
            None => return OriginDecision::Denied,
        };
        let candidate = match VerifiedOrigin::from_header(value) {
            Some(o) => o,
            None => return OriginDecision::Denied,
        };
        match self {
            Self::DenyAll => OriginDecision::Denied,
            Self::AllowExact { origin } => {
                if origin == &candidate {
                    OriginDecision::Allowed
                } else {
                    OriginDecision::Denied
                }
            }
            Self::AllowSet { origins } => {
                if origins.iter().any(|o| o == &candidate) {
                    OriginDecision::Allowed
                } else {
                    OriginDecision::Denied
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn hv(s: &str) -> HeaderValue {
        HeaderValue::from_str(s).expect("valid header value")
    }

    #[test]
    fn deny_all_rejects_everything() {
        let p = OriginPolicy::deny_all();
        assert_eq!(p.authorize(None), OriginDecision::Denied);
        assert_eq!(
            p.authorize(Some(&hv("http://localhost:3000"))),
            OriginDecision::Denied
        );
    }

    #[test]
    fn allow_exact_matches_only_that_origin() {
        let p = OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("http://localhost:3000"));
        assert_eq!(
            p.authorize(Some(&hv("http://localhost:3000"))),
            OriginDecision::Allowed
        );
        // Case-insensitive on the header side (verified normalizes to lower).
        assert_eq!(
            p.authorize(Some(&hv("HTTP://Localhost:3000"))),
            OriginDecision::Allowed
        );
        assert_eq!(
            p.authorize(Some(&hv("http://evil.example"))),
            OriginDecision::Denied
        );
    }

    #[test]
    fn allow_set_matches_any_listed_origin() {
        let p = OriginPolicy::allow_set(vec![
            VerifiedOrigin::from_trusted("http://localhost:3000"),
            VerifiedOrigin::from_trusted("https://app.example"),
        ]);
        assert_eq!(
            p.authorize(Some(&hv("http://localhost:3000"))),
            OriginDecision::Allowed
        );
        assert_eq!(
            p.authorize(Some(&hv("https://app.example"))),
            OriginDecision::Allowed
        );
        assert_eq!(
            p.authorize(Some(&hv("https://evil.example"))),
            OriginDecision::Denied
        );
    }

    #[test]
    fn missing_origin_is_rejected_by_non_denyall_policies() {
        let p = OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("http://localhost:3000"));
        assert_eq!(
            p.authorize(None),
            OriginDecision::Denied,
            "absent origin must be rejected"
        );
    }

    #[test]
    fn non_ascii_origin_is_rejected() {
        let p = OriginPolicy::allow_exact(VerifiedOrigin::from_trusted("http://localhost:3000"));
        // A non-ASCII byte in the header is malformed; reject.
        let bad = HeaderValue::from_bytes(&[0xFF, 0xFE]).expect("bytes ok as header");
        assert_eq!(p.authorize(Some(&bad)), OriginDecision::Denied);
    }
}