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)
- Trusted origin: the raw
Origin header bytes must exactly equal a registered string. add_trusted_origin stores the value verbatim.
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:
- 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.)
- 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.
- 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.
- 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:
- the registered trusted origin(s) and/or the
Origin + Host headers involved,
- whether the client is a real browser or another type of client/proxy,
- what you expected to happen.
Depending on feedback, options include:
- keep strict matching (status quo, matches Go) and improve docs / add a debug warning when a registered origin isn't in canonical browser form;
- reintroduce canonicalization as an opt-in builder (e.g.
.canonicalize_origins()), defaulting to strict;
- 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:
- host case folding stays strictly ASCII — Unicode case folding / IDN normalization can collapse distinct domains (homograph and normalization-collision risks);
- default-port stripping (
:443/:80) only ever equates origins a browser also treats as identical;
- 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
Context
The
csrfmiddleware (added in #699) ports the cross-origin protection scheme introduced in Go 1.25 / Filippo Valsorda's blog post. It compares the request'sOriginheader against (a) registered trusted origins and (b) theHostheader.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)
Originheader bytes must exactly equal a registered string.add_trusted_originstores the value verbatim.Origin/Hostfallback: theOrigin's authority (host[:port], scheme stripped) must byte-equal theHostheader.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:
Origin: https://Example.comvsHost: example.comis no match. (Browsers send lowercased hosts, so this is mostly a hand-crafted-client / proxy concern.)https://example.com:443, or a request withOrigin: https://example.com:4434. gainstHost: example.com, never matches the implicit-port form. Browsers omit default ports, so an explicit:443/:80in a trusted origin is silently dead.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.xn--…), since that's what browsers send.In practice browsers normalize the
Originthey 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 (incsrf/url.rs):Trusted-origin check (canonicalized both the stored value and the request
Origin):Origin/Hostcheck (ASCII-insensitive host, effective-port comparison with scheme defaults, fall back to the request URI whenHostis absent/unparseable):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:
Origin+Hostheaders involved,Depending on feedback, options include:
.canonicalize_origins()), defaulting to strict;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::443/:80) only ever equates origins a browser also treats as identical;Host/:authorityvalue 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
src/net/http/csrf.go)