Skip to content

CSRF: strict byte-matching of Origin might be brittle #701

Description

@yawn

Context

The csrf middleware (added in #699) ports the cross-origin protection scheme introduced in Go 1.25 / Filippo Valsorda's blog post. It compares the request's Origin header against (a) registered trusted origins and (b) the Host header.

During review of #699 we decided to match the Go reference exactly: both comparisons are now strict byte equality, with no canonicalization. We had initially written a canonicalizing implementation and removed it. This issue tracks the concern that strict matching may be brittle in practice, and asks users to report whether it bites them — before we consider adding (potentially opt-in) normalization back.

Current behavior (strict, matches Go)

  1. Trusted origin: the raw Origin header bytes must exactly equal a registered string. add_trusted_origin stores the value verbatim.
  2. Origin/Host fallback: the Origin's authority (host[:port], scheme stripped) must byte-equal the Host header.

No case folding, no default-port normalization, no trailing-slash handling.

Why this might be brittle

These all cause a semantically same-origin / trusted request to be rejected, or a trusted origin to silently never match:

  1. Host case. Origin: https://Example.com vs Host: example.com is no match. (Browsers send lowercased hosts, so this is mostly a hand-crafted-client / proxy concern.)
  2. Explicit default ports. Registering https://example.com:443, or a request with Origin: https://example.com:443 4. gainst Host: example.com, never matches the implicit-port form. Browsers omit default ports, so an explicit :443/:80 in a trusted origin is silently dead.
  3. Trailing slash / form variants. add_trusted_origin("https://example.com/") is now rejected as a path component (parity with Go), but the broader point stands: the registered value must be byte-identical to what the browser sends.
  4. IDN hosts. Must be registered in punycode (xn--…), since that's what browsers send.

In practice browsers normalize the Origin they emit, so for real browser traffic strict matching should not be a problem.

The alternative we removed (canonicalization)

For reference, the canonicalizing implementation that was removed in #699. It lower-cased the host and stripped default ports on both sides of each comparison, so incidental form differences didn't reject a legitimate request.

UriExt::canonical + port helpers (in csrf/url.rs):

/// Returns the canonical `scheme://host[:port]`: scheme http/https, host
/// ASCII-lowercased, default ports (80/443) stripped, other ports preserved.
fn canonical(&self) -> Option<String> {
    let scheme = match self.scheme_str()? {
        s @ ("http" | "https") => s,
        _ => return None,
    };

    let host = self.host().filter(|h| !h.is_empty())?.to_ascii_lowercase();
    let default: u16 = if scheme == "https" { 443 } else { 80 };

    Some(match self.port_u16() {
        Some(p) if p != default => format!("{scheme}://{host}:{p}"),
        _ => format!("{scheme}://{host}"),
    })
}

fn effective_port(&self) -> Option<u16> {
    self.port_u16().or_else(|| self.scheme_default_port())
}

fn scheme_default_port(&self) -> Option<u16> {
    match self.scheme_str() {
        Some("https") => Some(443),
        Some("http") => Some(80),
        _ => None,
    }
}

Trusted-origin check (canonicalized both the stored value and the request Origin):

let trusted = origin_uri
    .as_ref()
    .and_then(|u| u.canonical())
    .map_or(false, |s| self.trusted_origins.contains(&s));

Origin/Host check (ASCII-insensitive host, effective-port comparison with scheme defaults, fall back to the request URI when Host is absent/unparseable):

if let Some(uri) = &origin_uri {
    let authority = host
        .and_then(|b| str::from_utf8(b).ok())
        .and_then(|s| s.parse::<http::uri::Authority>().ok());

    // fall back to the request URI when the Host header is missing or unparseable
    let (host_name, port_host) = match authority.as_ref() {
        Some(a) => (Some(a.host()), a.port_u16()),
        None => (req.uri().host(), req.uri().port_u16()),
    };

    if let (Some(origin_host), Some(host_name)) = (uri.host(), host_name) {
        let port_origin = uri.effective_port();
        // Host carries no scheme; assume Origin's scheme so an implicit Host port
        // resolves to Origin's scheme default (443/80) rather than inheriting
        // Origin's explicit port.
        let port_host = port_host.or_else(|| uri.scheme_default_port());

        if origin_host.eq_ignore_ascii_case(host_name) && port_origin == port_host {
            return Ok(());
        }
    }
}

Ask

If you hit a case where strict byte-matching rejects a request you consider legitimate (or a trusted origin silently never matches), please comment with:

  1. the registered trusted origin(s) and/or the Origin + Host headers involved,
  2. whether the client is a real browser or another type of client/proxy,
  3. what you expected to happen.

Depending on feedback, options include:

  1. keep strict matching (status quo, matches Go) and improve docs / add a debug warning when a registered origin isn't in canonical browser form;
  2. reintroduce canonicalization as an opt-in builder (e.g. .canonicalize_origins()), defaulting to strict;
  3. canonicalize by default (diverge from Go).

A second question — and arguably the more important one — for anyone weighing options 2 and 3: would reintroducing normalization open new attack surface? Strict matching is conservative precisely because it asserts no equivalences: it trusts only a byte-identical Origin. Every normalization rule instead declares two textually-different values to be the same origin, and any rule broader than the browser's own same-origin notion is a CSRF bypass. Before adding normalization back we'd want confidence that, at minimum:

  1. host case folding stays strictly ASCII — Unicode case folding / IDN normalization can collapse distinct domains (homograph and normalization-collision risks);
  2. default-port stripping (:443/:80) only ever equates origins a browser also treats as identical;
  3. no canonicalization step makes an attacker-influenceable Host / :authority value compare equal to a trusted origin.

If you can show a normalization rule that would admit a request strict matching correctly rejects, that argues for staying strict (option 1) or scoping any normalization very tightly.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions