tower-rs / tower-rs/tower-http
CSRF: strict byte-matching of `Origin` might be brittle
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 913
- Forks
- 231
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 8
Description
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
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:
- Host case.
Origin: https://Example.comvsHost: example.comis 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 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. - 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+Hostheaders 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/: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
- PR: #699
- Go reference: https://pkg.go.dev/net/http#CrossOriginProtection (
src/net/http/csrf.go) - Background: https://words.filippo.io/csrf/
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading the CSRF middleware and the removed UriExt::canonical and port helpers in csrf/url.rs, then compare the current behavior with PR #699 and Go's CrossOriginProtection reference. Reproduce the listed Origin/Host variants with real or non-browser clients if possible; done requires evidence-based feedback and a decided scope for strict matching, documentation, or opt-in normalization.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100