diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 8368712..ec30543 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A feed's username and password go to its own host alone: not to its episodes on other hosts, nor to the feeds an OPML lists on other hosts, which took the OPML's password on every check (#327, #328). - A check of one feed asks at most eight hosts whether its artwork is on https. A feed could name artwork on as many hosts as it liked, each asked in turn for up to 20 seconds, and hold up every other feed's check for hours (#339). - An OPML or Patreon list may bring in up to 2,000 feeds; one with more shows an error and adds none. A list of tens of thousands held up checking for everyone, and adding its feeds read the whole feeds table once for each (#337). +- A request that changes something is refused when a browser sent it from another site. A page on another site, a sibling subdomain included, could mark everything read, start downloads, subscribe you to a Directory feed or sign you out (#336). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index 57fd2bc..e09c958 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -89,6 +89,7 @@ pub fn router(state: WebState) -> Router { .route("/api/login", post(login)) .route("/api/login-check", get(login_check)) .route_layer(middleware::from_fn(name_span)) + .layer(middleware::from_fn(cross_site_refused)) .layer(middleware::from_fn(access_log)) .with_state(state) } @@ -1264,6 +1265,26 @@ impl IntoResponse for ApiError { mod tests { use super::*; + #[test] + fn a_change_sent_from_another_site_is_known() { + let h = |pairs: &[(&str, &str)]| { + let mut m = HeaderMap::new(); + for (k, v) in pairs { + m.insert(axum::http::HeaderName::from_bytes(k.as_bytes()).unwrap(), v.parse().unwrap()); + } + m + }; + assert!(!from_elsewhere(&h(&[("sec-fetch-site", "same-origin")]))); + assert!(!from_elsewhere(&h(&[("sec-fetch-site", "none")])), "typed in the address bar"); + assert!(from_elsewhere(&h(&[("sec-fetch-site", "same-site")])), "a sibling subdomain"); + assert!(from_elsewhere(&h(&[("sec-fetch-site", "cross-site")]))); + let host = ("host", "ipodderx.sdf1.net"); + assert!(!from_elsewhere(&h(&[host, ("origin", "https://ipodderx.sdf1.net")]))); + assert!(from_elsewhere(&h(&[host, ("origin", "https://evil.sdf1.net")]))); + assert!(from_elsewhere(&h(&[host, ("origin", "null")]))); + assert!(!from_elsewhere(&h(&[host])), "a script with a token sends neither"); + } + #[test] fn only_inert_downloads_open_in_the_browser() { for t in ["audio/mpeg", "video/mp4", "image/jpeg", "application/pdf", "text/plain; charset=utf-8"] { @@ -2668,6 +2689,30 @@ async fn name_span(route: MatchedPath, req: Request, next: Next) -> Response { /// /// The log view polls `/api/logs`, so logging that path would generate a line per poll /// forever -- a feed of nothing but its own requests. +/// A request that changes something is refused when a browser sent it from another site (#336). +/// The session cookie's SameSite=Lax stopped a form on an unrelated site, but not one on a sibling +/// subdomain, nor anything signed in by Cloudflare Access's own cookie; a JSON body forced a +/// preflight, and the routes that take none (Read all, Download, Sign out) had nothing. +async fn cross_site_refused(req: Request, next: Next) -> Response { + let reads = + matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS); + if !reads && from_elsewhere(req.headers()) { + return (StatusCode::FORBIDDEN, "refused: sent from another site").into_response(); + } + next.run(req).await +} + +/// Whether a browser says the request came from a page on another site. `Sec-Fetch-Site` says so +/// in every current browser; an older one is judged by `Origin` against `Host`. A request with +/// neither is not a browser's, a script with a token, and is let through. +fn from_elsewhere(h: &HeaderMap) -> bool { + let get = |name: &str| h.get(name).and_then(|v| v.to_str().ok()); + if let Some(site) = get("sec-fetch-site") { + return !matches!(site, "same-origin" | "none"); + } + get("origin").is_some_and(|origin| origin.split_once("://").map(|(_, host)| host) != get("host")) +} + async fn access_log(req: Request, next: Next) -> Response { let path = req.uri().path().to_owned(); let method = req.method().clone();