From 094fb4ee2eb2e03f7235499554d41f165002725c Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5" Date: Sat, 17 Oct 2026 20:32:44 +0000 Subject: [PATCH] feat(oidc): HostSessions::is_same_origin for a host's own writes A host that admits its session cookie on its own state-changing routes needs to tell its pages from everyone else's. The cookie is SameSite=Lax, so the browser also sends it with requests from other hosts of the same site (two hosts under one registrable domain, e.g. under one tailnet's ts.net name), and Sec-Fetch-Site: same-site does not tell them apart. Only Origin does. is_same_origin is true only when the request holds exactly one Origin, equal byte for byte to the host's origin. A missing Origin is refused: a browser sends it on every fetch other than GET or HEAD and on every form POST. The logout route's own check is unchanged. Co-Authored-By: Claude Opus 5.5 --- cli-framework-oidc/README.md | 6 +++ cli-framework-oidc/src/host_session/mod.rs | 19 +++++++ cli-framework-oidc/tests/host_session.rs | 62 +++++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/cli-framework-oidc/README.md b/cli-framework-oidc/README.md index d6c33b7..61a2839 100644 --- a/cli-framework-oidc/README.md +++ b/cli-framework-oidc/README.md @@ -271,6 +271,12 @@ match sessions.resolve(request.headers()).await { cross-site`, or a POST with neither header is refused with 403. It ends the realm session with the refresh token, clears the cookie, and redirects to the realm's end-session endpoint. +- **Your own writes**: run `sessions.is_same_origin(request.headers())` on + every state-changing route the cookie admits, and refuse the request when it + is `false`. It is `true` only for exactly one `Origin` equal to the host's + origin. `SameSite=Lax` sends the cookie with requests from other hosts of the + same site (say `a.example.ts.net` and `b.example.ts.net`), and + `Sec-Fetch-Site: same-site` does not tell them apart, so only `Origin` can. - **Cookie size**: `HostSessions::new` refuses a configuration whose expected tokens would not fit `max_cookie_bytes` (4096), and sign-in refuses real tokens that don't. The sealed size is logged as `cookie_bytes`. diff --git a/cli-framework-oidc/src/host_session/mod.rs b/cli-framework-oidc/src/host_session/mod.rs index 97fef3e..f745e54 100644 --- a/cli-framework-oidc/src/host_session/mod.rs +++ b/cli-framework-oidc/src/host_session/mod.rs @@ -409,6 +409,25 @@ impl HostSessions { &self.inner.origin } + /// Whether a request comes from the host's own pages: `true` only when + /// `headers` hold exactly one `Origin`, equal byte for byte to + /// [`origin`](Self::origin). + /// + /// Run it on every state-changing route the session cookie admits. The + /// cookie is `SameSite=Lax`, so the browser also sends it with requests + /// from other hosts of the same site (two hosts under one registrable + /// domain, such as `a.example.ts.net` and `b.example.ts.net`), and + /// `Sec-Fetch-Site: same-site` does not tell them apart; only `Origin` + /// does. A request without `Origin` is refused: a browser sends it on + /// every `fetch` other than `GET` or `HEAD` and on every form POST. + pub fn is_same_origin(&self, headers: &HeaderMap) -> bool { + let mut sent = headers.get_all(header::ORIGIN).iter(); + match (sent.next(), sent.next()) { + (Some(origin), None) => origin.as_bytes() == self.inner.origin.as_bytes(), + _ => false, + } + } + /// Reads the session cookie from `headers` and decides whether the /// session is live: binding, idle, expiry, and a refresh when the access /// token is within `refresh_skew` of expiry. diff --git a/cli-framework-oidc/tests/host_session.rs b/cli-framework-oidc/tests/host_session.rs index 637e24e..decb538 100644 --- a/cli-framework-oidc/tests/host_session.rs +++ b/cli-framework-oidc/tests/host_session.rs @@ -2,7 +2,7 @@ //! end-session endpoints), driven through the router and `resolve`. use cli_framework::axum::body::Body; -use cli_framework::axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; +use cli_framework::axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode}; use cli_framework::axum::response::Response; use cli_framework_oidc::host_session::{ ClientSecret, Clock, EndReason, HostSessionConfig, HostSessions, Resolution, SessionKey, @@ -823,6 +823,66 @@ async fn logout_refuses_a_post_with_neither_origin_nor_sec_fetch_site() { } } +#[tokio::test] +async fn is_same_origin_needs_exactly_one_origin_equal_to_the_hosts() { + let realm = Realm::start().await; + let s = HostSessions::new(config(&realm, &TestClock::new())).unwrap(); + let headers = |sent: &[(&'static str, &str)]| { + let mut headers = HeaderMap::new(); + for (name, value) in sent { + headers.append( + HeaderName::from_static(name), + HeaderValue::from_str(value).unwrap(), + ); + } + headers + }; + + assert_eq!(s.origin(), ORIGIN); + assert!(s.is_same_origin(&headers(&[("origin", ORIGIN)]))); + assert!(s.is_same_origin(&headers(&[ + ("origin", ORIGIN), + ("sec-fetch-site", "same-origin") + ]))); + + for (what, sent) in [ + ("no header", &[][..]), + ( + "Sec-Fetch-Site same-origin alone", + &[("sec-fetch-site", "same-origin")], + ), + ( + "Sec-Fetch-Site same-site alone", + &[("sec-fetch-site", "same-site")], + ), + ( + "another host of the same site", + &[ + ("origin", "http://other.localhost:8080"), + ("sec-fetch-site", "same-site"), + ], + ), + ( + "a look-alike host", + &[("origin", "http://localhost:8080.evil.example")], + ), + ("another port", &[("origin", "http://localhost:8081")]), + ("another scheme", &[("origin", "https://localhost:8080")]), + ("a trailing slash", &[("origin", "http://localhost:8080/")]), + ("an opaque origin", &[("origin", "null")]), + ( + "two equal origins", + &[("origin", ORIGIN), ("origin", ORIGIN)], + ), + ( + "the host's and another", + &[("origin", ORIGIN), ("origin", "https://evil.example")], + ), + ] { + assert!(!s.is_same_origin(&headers(sent)), "{what}"); + } +} + #[tokio::test] async fn session_ttl_sets_max_age() { let realm = Realm::start().await;