fix(media-proxy): allow the desktop app origin and preflight (#3325)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 23:21:10 +02:00
1 parent 0febad324c
commit d0b4816a5f
7 files changed
+218 -6

No files matched your search

@@ -91,10 +91,12 @@ An operator can limit which web origins read media through CORS. `FLUXER_MEDIA_P
| `report` | Every response is the same as under `off`, and each read `enforce` would refuse is logged | | `report` | Every response is the same as under `off`, and each read `enforce` would refuse is logged |
| `enforce` | A read from an origin outside the allowlist returns 403 | | `enforce` | A read from an origin outside the allowlist returns 403 |
Under `enforce`, an `Origin` is allowed when it equals the serialised form of an entry in `FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS` byte for byte. Anything else is refused, including `null`, a repeated `Origin` header, and a value that is not ASCII. A refused read returns 403 on every read path, and the Media Proxy reads no object and fetches no external URL for it. [Media Proxy settings](/operator/configuration/#media-proxy-settings) defines how an entry is parsed and serialised. Under `enforce`, an `Origin` is allowed when it equals the serialised form of an entry in `FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS` byte for byte. The desktop app origin `fluxer-app://app` is always allowed and needs no entry. Anything else is refused, including `null`, a repeated `Origin` header, and a value that is not ASCII. A refused read returns 403 on every read path, and the Media Proxy reads no object and fetches no external URL for it. [Media Proxy settings](/operator/configuration/#media-proxy-settings) defines how an entry is parsed and serialised.
A read with no `Origin` is served without `Access-Control-Allow-Origin`. A browser sends no `Origin` for an `<img>` or a `<video>` without `crossorigin`, so those elements still load. An allowed read gets its own origin back in `Access-Control-Allow-Origin` on a media representation and on a 416. No error has CORS headers. A read with no `Origin` is served without `Access-Control-Allow-Origin`. A browser sends no `Origin` for an `<img>` or a `<video>` without `crossorigin`, so those elements still load. An allowed read gets its own origin back in `Access-Control-Allow-Origin` on a media representation and on a 416. No error has CORS headers.
A preflight `OPTIONS` request from an allowed origin gets a 204 that allows `GET` and `HEAD` with the `Range` header. A preflight from a refused origin gets the same 403 as a read.
Under `enforce`, every response to a `GET` or `HEAD` on a read path has `Vary: Accept-Encoding, Origin`, the 403 for a refused `Origin` included. Under `enforce`, every response to a `GET` or `HEAD` on a read path has `Vary: Accept-Encoding, Origin`, the 403 for a refused `Origin` included.
`report` changes no response. The Media Proxy logs a warning with the reason `cors_origin_would_deny` for each read `enforce` would refuse. `report` changes no response. The Media Proxy logs a warning with the reason `cors_origin_would_deny` for each read `enforce` would refuse.
@@ -1441,7 +1441,7 @@ Default `off`. The [cross-origin read policy](/media-proxy/overview/#cross-origi
#### `FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS` #### `FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS`
Default empty. Comma-separated origins that may read media through CORS, such as `https://web.fluxer.app`. Spaces around an entry and empty entries are ignored. Each entry is parsed as a URL and must be `http` or `https` with no credentials, query, fragment, or path beyond `/`. Its host must be an IPv4 address, a bracketed IPv6 address, or a domain name whose labels are ASCII letters, digits, and hyphens after parsing. So a wildcard such as `https://*.fluxer.app`, a brace pattern, an underscore, a trailing dot on a domain name, or any non-ASCII character fails. Write an internationalised domain name in its punycode form. Any failing entry makes the process exit. An entry is compared in the form a browser sends. `https://Web.Fluxer.App:443/` matches `https://web.fluxer.app`. Required when `FLUXER_MEDIA_PROXY_CORS_MODE` is `report` or `enforce`. When the policy is `report` or `enforce`, the Media Proxy logs the parsed list once at startup. When `FLUXER_MEDIA_PROXY_MODE` is `static` or `relay`, the Media Proxy ignores it. Default empty. Comma-separated origins that may read media through CORS, such as `https://web.fluxer.app`. Spaces around an entry and empty entries are ignored. Each entry is parsed as a URL and must be `http` or `https` with no credentials, query, fragment, or path beyond `/`. Its host must be an IPv4 address, a bracketed IPv6 address, or a domain name whose labels are ASCII letters, digits, and hyphens after parsing. So a wildcard such as `https://*.fluxer.app`, a brace pattern, an underscore, a trailing dot on a domain name, or any non-ASCII character fails. Write an internationalised domain name in its punycode form. The one exception is the desktop app origin `fluxer-app://app`. It is accepted as an entry and is always allowed without one. Any failing entry makes the process exit. An entry is compared in the form a browser sends. `https://Web.Fluxer.App:443/` matches `https://web.fluxer.app`. Required when `FLUXER_MEDIA_PROXY_CORS_MODE` is `report` or `enforce`. When the policy is `report` or `enforce`, the Media Proxy logs the parsed list once at startup. When `FLUXER_MEDIA_PROXY_MODE` is `static` or `relay`, the Media Proxy ignores it.
Compose forwards it from `.env`, and defaults it to the public origin, built from `FLUXER_PUBLIC_ORIGIN`, or from `FLUXER_PUBLIC_SCHEME`, `FLUXER_DOMAIN`, and `FLUXER_PUBLIC_PORT`. That origin is the only entry, so the web app the instance serves reads its own media. The hosted web client and the desktop app run on `https://web.fluxer.app`. To let them read this instance's media through CORS, set the list in `.env` to the public origin and `https://web.fluxer.app`, such as `https://chat.example.com,https://web.fluxer.app`. A value set in `.env` replaces the Compose value, so it must name the public origin too. Compose forwards it from `.env`, and defaults it to the public origin, built from `FLUXER_PUBLIC_ORIGIN`, or from `FLUXER_PUBLIC_SCHEME`, `FLUXER_DOMAIN`, and `FLUXER_PUBLIC_PORT`. That origin is the only entry, so the web app the instance serves reads its own media. The hosted web client and the desktop app run on `https://web.fluxer.app`. To let them read this instance's media through CORS, set the list in `.env` to the public origin and `https://web.fluxer.app`, such as `https://chat.example.com,https://web.fluxer.app`. A value set in `.env` replaces the Compose value, so it must name the public origin too.
+4 -1
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use super::{BucketStyle, DeploymentMode, PolicyMode, StorageBackend}; use super::{BucketStyle, DeploymentMode, PolicyMode, StorageBackend};
use crate::secret::SecretBytes; use crate::{constants::DESKTOP_APP_ORIGIN, secret::SecretBytes};
use base64::{Engine as _, engine::general_purpose}; use base64::{Engine as _, engine::general_purpose};
use http::HeaderValue; use http::HeaderValue;
@@ -121,6 +121,9 @@ fn parse_origin(entry: &str) -> Option<HeaderValue> {
if !entry.is_ascii() { if !entry.is_ascii() {
return None; return None;
} }
if entry == DESKTOP_APP_ORIGIN {
return Some(HeaderValue::from_static(DESKTOP_APP_ORIGIN));
}
let url = url::Url::parse(entry).ok()?; let url = url::Url::parse(entry).ok()?;
let bare = matches!(url.scheme(), "http" | "https") let bare = matches!(url.scheme(), "http" | "https")
&& url.host().is_some_and(is_origin_host) && url.host().is_some_and(is_origin_host)
+10 -1
View File
@@ -66,6 +66,11 @@ fn allowed_origins_are_normalised_to_their_browser_serialisation() {
("https://web.fluxer.app/", vec!["https://web.fluxer.app"]), ("https://web.fluxer.app/", vec!["https://web.fluxer.app"]),
("https://web.fluxer.app:443", vec!["https://web.fluxer.app"]), ("https://web.fluxer.app:443", vec!["https://web.fluxer.app"]),
("http://localhost:8088", vec!["http://localhost:8088"]), ("http://localhost:8088", vec!["http://localhost:8088"]),
("fluxer-app://app", vec!["fluxer-app://app"]),
(
"https://web.fluxer.app,fluxer-app://app,fluxer-app://app",
vec!["https://web.fluxer.app", "fluxer-app://app"],
),
("http://127.0.0.1:8088", vec!["http://127.0.0.1:8088"]), ("http://127.0.0.1:8088", vec!["http://127.0.0.1:8088"]),
("http://[::1]:8088", vec!["http://[::1]:8088"]), ("http://[::1]:8088", vec!["http://[::1]:8088"]),
( (
@@ -94,7 +99,11 @@ fn rejects_an_allowed_origin_that_is_not_a_bare_http_origin() {
"web.fluxer.app", "web.fluxer.app",
"ftp://web.fluxer.app", "ftp://web.fluxer.app",
"file:///tmp/x", "file:///tmp/x",
"fluxer-app://app", "fluxer-app://app/",
"fluxer-app://evil",
"fluxer-app://app:443",
"FLUXER-APP://APP",
"other-app://app",
"https://[email protected]", "https://[email protected]",
"https://user:[email protected]", "https://user:[email protected]",
"https://web.fluxer.app/app", "https://web.fluxer.app/app",
+1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
pub const MAX_MEDIA_PROXY_BYTES: usize = 500 * 1024 * 1024; pub const MAX_MEDIA_PROXY_BYTES: usize = 500 * 1024 * 1024;
pub const DESKTOP_APP_ORIGIN: &str = "fluxer-app://app";
pub const OUTBOUND_USER_AGENT: &str = pub const OUTBOUND_USER_AGENT: &str =
"Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)"; "Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)";
+46 -1
View File
@@ -1,12 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{request_log, server::response::error::text_with_source}; use crate::{
constants::DESKTOP_APP_ORIGIN, http_headers, request_log,
server::response::error::text_with_source,
};
use axum::{ use axum::{
body::Body,
http::{HeaderMap, HeaderValue, Method, StatusCode, header}, http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::Response, response::Response,
}; };
const VARY_ON_ORIGIN: &str = "Accept-Encoding, Origin"; const VARY_ON_ORIGIN: &str = "Accept-Encoding, Origin";
const VARY_ON_PREFLIGHT: &str =
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers";
const PREFLIGHT_ALLOW_METHODS: &str = "GET, HEAD, OPTIONS";
const PREFLIGHT_ALLOW_HEADERS: &str = "Range";
const PREFLIGHT_MAX_AGE_SECONDS: &str = "600";
const ANY_ORIGIN: &str = "*";
const ORIGIN_LOG_BYTES_MAX: usize = 256; const ORIGIN_LOG_BYTES_MAX: usize = 256;
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
@@ -24,6 +34,9 @@ pub(in crate::server) fn check_origin(allowed: &[HeaderValue], request: &HeaderM
if origins.next().is_some() { if origins.next().is_some() {
return OriginCheck::Refused; return OriginCheck::Refused;
} }
if origin.as_bytes() == DESKTOP_APP_ORIGIN.as_bytes() {
return OriginCheck::Allowed(HeaderValue::from_static(DESKTOP_APP_ORIGIN));
}
allowed allowed
.iter() .iter()
.find(|candidate| candidate.as_bytes() == origin.as_bytes()) .find(|candidate| candidate.as_bytes() == origin.as_bytes())
@@ -45,6 +58,38 @@ pub(in crate::server) fn refused_response(request: &HeaderMap) -> Response {
response response
} }
pub(in crate::server) fn is_preflight(method: &Method, request: &HeaderMap) -> bool {
method == Method::OPTIONS
&& request.contains_key(header::ORIGIN)
&& request.contains_key(header::ACCESS_CONTROL_REQUEST_METHOD)
}
pub(in crate::server) fn preflight_response(check: &OriginCheck) -> Response {
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::NO_CONTENT;
let headers = response.headers_mut();
http_headers::add_security_headers(headers);
let allow_origin = match check {
OriginCheck::Allowed(origin) => origin.clone(),
OriginCheck::Absent | OriginCheck::Refused => HeaderValue::from_static(ANY_ORIGIN),
};
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
headers.insert(
header::ACCESS_CONTROL_ALLOW_METHODS,
HeaderValue::from_static(PREFLIGHT_ALLOW_METHODS),
);
headers.insert(
header::ACCESS_CONTROL_ALLOW_HEADERS,
HeaderValue::from_static(PREFLIGHT_ALLOW_HEADERS),
);
headers.insert(
header::ACCESS_CONTROL_MAX_AGE,
HeaderValue::from_static(PREFLIGHT_MAX_AGE_SECONDS),
);
headers.insert(header::VARY, HeaderValue::from_static(VARY_ON_PREFLIGHT));
response
}
pub(in crate::server) fn log_would_refuse(method: &Method, path: &str, request: &HeaderMap) { pub(in crate::server) fn log_would_refuse(method: &Method, path: &str, request: &HeaderMap) {
tracing::warn!( tracing::warn!(
reason = "cors_origin_would_deny", reason = "cors_origin_would_deny",
@@ -41,6 +41,9 @@ pub(in crate::server) async fn catch_all(
request: Request<Body>, request: Request<Body>,
) -> Response { ) -> Response {
let method = request.method().clone(); let method = request.method().clone();
if app.cfg.mode != DeploymentMode::Relay && cors::is_preflight(&method, request.headers()) {
return preflight(&app, request.uri().path(), request.headers());
}
if method != Method::GET && method != Method::HEAD { if method != Method::GET && method != Method::HEAD {
return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed"); return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed");
} }
@@ -89,6 +92,21 @@ pub(in crate::server) async fn catch_all(
response response
} }
fn preflight(app: &AppState, path: &str, headers: &HeaderMap) -> Response {
if app.cfg.cors.mode == PolicyMode::Off {
return cors::preflight_response(&OriginCheck::Absent);
}
let check = cors::check_origin(&app.cfg.cors.allowed_origins, headers);
if check != OriginCheck::Refused {
return cors::preflight_response(&check);
}
if app.cfg.cors.mode == PolicyMode::Enforce {
return cors::refused_response(headers);
}
cors::log_would_refuse(&Method::OPTIONS, path, headers);
cors::preflight_response(&check)
}
async fn serve_public_read(app: &Arc<AppState>, read: PublicRead<'_>) -> Response { async fn serve_public_read(app: &Arc<AppState>, read: PublicRead<'_>) -> Response {
let PublicRead { let PublicRead {
method, method,
@@ -189,7 +207,7 @@ fn without_signature_parameters(params: &HashMap<String, String>) -> HashMap<Str
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::Config; use crate::{config::Config, constants::DESKTOP_APP_ORIGIN};
use axum::{ use axum::{
body::to_bytes, body::to_bytes,
http::{HeaderValue, header}, http::{HeaderValue, header},
@@ -852,6 +870,140 @@ mod tests {
); );
} }
fn preflight_request(path: &str, origin: &str, requested: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.method(Method::OPTIONS)
.uri(path)
.header(header::ORIGIN, origin);
if let Some(requested) = requested {
builder = builder.header(header::ACCESS_CONTROL_REQUEST_METHOD, requested);
}
builder.body(Body::empty()).expect("preflight request")
}
#[tokio::test]
async fn enforce_serves_the_desktop_app_origin_without_configuring_it() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
write_stored_representations(root);
let app = dispatch_app("mp", root, ENFORCE);
for method in [Method::GET, Method::HEAD] {
let response = dispatch_request(
&app,
read_request(
method.clone(),
THEME_PATH,
&[DESKTOP_APP_ORIGIN.as_bytes()],
None,
),
)
.await;
assert_eq!(StatusCode::OK, response.status(), "{method}");
assert_eq!(
Some(DESKTOP_APP_ORIGIN.to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{method}"
);
assert_eq!(vec![ORIGIN_VARY.to_owned()], vary_values(&response));
}
for lookalike in [
"fluxer-app://app/",
"fluxer-app://evil",
"fluxer-app://app.evil.example",
"fluxer-app://app:443",
"FLUXER-APP://APP",
"fluxer-app:app",
] {
let response = dispatch_request(
&app,
read_request(Method::GET, THEME_PATH, &[lookalike.as_bytes()], None),
)
.await;
assert_eq!(StatusCode::FORBIDDEN, response.status(), "{lookalike}");
assert_eq!(
None,
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{lookalike}"
);
}
}
#[tokio::test]
async fn preflight_answers_allowed_origins_and_refuses_the_rest() {
let tmp = tempfile::tempdir().expect("storage root");
let root = tmp.path().canonicalize().expect("canonical storage root");
let root = root.as_path();
let enforce = dispatch_app("mp", root, ENFORCE);
for origin in [WEB, DESKTOP_APP_ORIGIN] {
for requested in ["GET", "HEAD"] {
let response = dispatch_request(
&enforce,
preflight_request(THEME_PATH, origin, Some(requested)),
)
.await;
let label = format!("{origin} {requested}");
assert_eq!(StatusCode::NO_CONTENT, response.status(), "{label}");
assert_eq!(
Some(origin.to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{label}"
);
assert_eq!(
Some("GET, HEAD, OPTIONS".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_METHODS),
"{label}"
);
assert_eq!(
Some("Range".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_HEADERS),
"{label}"
);
assert_eq!(
vec![
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
.to_owned()
],
vary_values(&response),
"{label}"
);
assert!(body_of(response).await.is_empty(), "{label}");
}
}
let refused =
dispatch_request(&enforce, preflight_request(THEME_PATH, EVIL, Some("GET"))).await;
assert_eq!(StatusCode::FORBIDDEN, refused.status());
assert_eq!(
None,
header_value(&refused, header::ACCESS_CONTROL_ALLOW_ORIGIN)
);
let not_a_preflight =
dispatch_request(&enforce, preflight_request(THEME_PATH, WEB, None)).await;
assert_eq!(StatusCode::METHOD_NOT_ALLOWED, not_a_preflight.status());
for (mode, extra) in [("mp", &[][..]), ("mp", REPORT), ("static", &[][..])] {
let app = dispatch_app(mode, root, extra);
let response =
dispatch_request(&app, preflight_request(THEME_PATH, EVIL, Some("GET"))).await;
assert_eq!(StatusCode::NO_CONTENT, response.status(), "{mode}");
assert_eq!(
Some("*".to_owned()),
header_value(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN),
"{mode}"
);
}
let relay = dispatch_app("relay", root, &[]);
assert_eq!(
StatusCode::METHOD_NOT_ALLOWED,
dispatch_request(&relay, preflight_request(THEME_PATH, WEB, Some("GET")))
.await
.status()
);
}
#[tokio::test] #[tokio::test]
async fn enforce_applies_to_upload_mode_reads() { async fn enforce_applies_to_upload_mode_reads() {
let tmp = tempfile::tempdir().expect("storage root"); let tmp = tempfile::tempdir().expect("storage root");