mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(media-proxy): allow the desktop app origin and preflight (#3325)
This commit is contained in:
1 parent
0febad324c
commit
d0b4816a5f
7 files changed
+218
-6
No files matched your search
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 http::HeaderValue;
|
||||
|
||||
@@ -121,6 +121,9 @@ fn parse_origin(entry: &str) -> Option<HeaderValue> {
|
||||
if !entry.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
if entry == DESKTOP_APP_ORIGIN {
|
||||
return Some(HeaderValue::from_static(DESKTOP_APP_ORIGIN));
|
||||
}
|
||||
let url = url::Url::parse(entry).ok()?;
|
||||
let bare = matches!(url.scheme(), "http" | "https")
|
||||
&& url.host().is_some_and(is_origin_host)
|
||||
|
||||
@@ -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:443", vec!["https://web.fluxer.app"]),
|
||||
("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://[::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",
|
||||
"ftp://web.fluxer.app",
|
||||
"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://user:[email protected]",
|
||||
"https://web.fluxer.app/app",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 =
|
||||
"Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)";
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
// 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::{
|
||||
body::Body,
|
||||
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
@@ -24,6 +34,9 @@ pub(in crate::server) fn check_origin(allowed: &[HeaderValue], request: &HeaderM
|
||||
if origins.next().is_some() {
|
||||
return OriginCheck::Refused;
|
||||
}
|
||||
if origin.as_bytes() == DESKTOP_APP_ORIGIN.as_bytes() {
|
||||
return OriginCheck::Allowed(HeaderValue::from_static(DESKTOP_APP_ORIGIN));
|
||||
}
|
||||
allowed
|
||||
.iter()
|
||||
.find(|candidate| candidate.as_bytes() == origin.as_bytes())
|
||||
@@ -45,6 +58,38 @@ pub(in crate::server) fn refused_response(request: &HeaderMap) -> 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) {
|
||||
tracing::warn!(
|
||||
reason = "cors_origin_would_deny",
|
||||
|
||||
@@ -41,6 +41,9 @@ pub(in crate::server) async fn catch_all(
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
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 {
|
||||
return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed");
|
||||
}
|
||||
@@ -89,6 +92,21 @@ pub(in crate::server) async fn catch_all(
|
||||
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 {
|
||||
let PublicRead {
|
||||
method,
|
||||
@@ -189,7 +207,7 @@ fn without_signature_parameters(params: &HashMap<String, String>) -> HashMap<Str
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::{config::Config, constants::DESKTOP_APP_ORIGIN};
|
||||
use axum::{
|
||||
body::to_bytes,
|
||||
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]
|
||||
async fn enforce_applies_to_upload_mode_reads() {
|
||||
let tmp = tempfile::tempdir().expect("storage root");
|
||||
|
||||
Reference in new issue
Block a user