Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli-framework-oidc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 19 additions & 0 deletions cli-framework-oidc/src/host_session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 61 additions & 1 deletion cli-framework-oidc/tests/host_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading