Requests that change something are refused from another site (#336)

Six POST routes take no body, so a cross-origin form or no-cors fetch
reached them with no preflight: Sign out, Read all (one feed's and
every feed's), Download, Refresh a listing and subscribing to a
Directory feed. The ipx cookies are SameSite=Lax, which stops a form
on an unrelated site but not one on a sibling subdomain, and the
Cloudflare Access path does not use ipx's cookies at all.

cross_site_refused wraps every route, login included: anything but
GET, HEAD and OPTIONS is a 403 when Sec-Fetch-Site is not same-origin
or none, or, from a browser too old to send it, when Origin is not the
Host. A request with neither header is not a browser's (a script with
a token, the healthcheck, k6) and goes through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:33:39 +00:00
parent 46fe0dddbf
commit d1149e1236
2 changed files with 46 additions and 0 deletions

View File

@@ -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

View File

@@ -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();