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:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user