Cross-origin requests only from the front ends' origins (contract C-14)
This commit is contained in:
@@ -62,6 +62,9 @@ pub struct Http {
|
|||||||
pub url_https: String,
|
pub url_https: String,
|
||||||
pub allowed_endpoint: IfBlock,
|
pub allowed_endpoint: IfBlock,
|
||||||
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
|
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
|
||||||
|
/// inbuxa: origins allowed cross-origin access (contract C-14). Empty when
|
||||||
|
/// CORS is permissive (bootstrap, recovery, or `usePermissiveCors`).
|
||||||
|
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||||
pub use_forwarded: bool,
|
pub use_forwarded: bool,
|
||||||
pub redirect_root: Option<String>,
|
pub redirect_root: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -408,6 +411,17 @@ impl Http {
|
|||||||
#[cfg(not(feature = "dev_mode"))]
|
#[cfg(not(feature = "dev_mode"))]
|
||||||
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
|
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
|
||||||
|
|
||||||
|
// inbuxa: otherwise only the front ends' origins get cross-origin
|
||||||
|
// access, echoed per request (contract C-14)
|
||||||
|
let cors_origins = if use_permissive_cors {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
crate::manager::first_party::front_end_origins()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|origin| hyper::header::HeaderValue::from_str(&origin).ok())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
if use_permissive_cors {
|
if use_permissive_cors {
|
||||||
http_headers.push((
|
http_headers.push((
|
||||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
@@ -464,6 +478,7 @@ impl Http {
|
|||||||
http.rate_limit_anonymous
|
http.rate_limit_anonymous
|
||||||
},
|
},
|
||||||
response_headers: http_headers,
|
response_headers: http_headers,
|
||||||
|
cors_origins,
|
||||||
use_forwarded: http.use_x_forwarded,
|
use_forwarded: http.use_x_forwarded,
|
||||||
redirect_root: http.redirect_root,
|
redirect_root: http.redirect_root,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,43 @@ fn base_url(bp: &Bootstrap, system: &SystemSettings) -> String {
|
|||||||
format!("https://{host}")
|
format!("https://{host}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The origin (`scheme://host[:port]`) of a front end's address, lowercased,
|
||||||
|
/// without a default port. `None` if it isn't an `http` or `https` URL.
|
||||||
|
pub fn origin_of(url: &str) -> Option<String> {
|
||||||
|
let uri = url.trim().parse::<hyper::Uri>().ok()?;
|
||||||
|
let scheme = uri.scheme_str()?.to_ascii_lowercase();
|
||||||
|
let default_port = match scheme.as_str() {
|
||||||
|
"https" => 443,
|
||||||
|
"http" => 80,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let host = uri.host()?.to_ascii_lowercase();
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match uri.port_u16() {
|
||||||
|
Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
|
||||||
|
_ => format!("{scheme}://{host}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The origins allowed to make cross-origin requests (contract C-14): INBUXA
|
||||||
|
/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated).
|
||||||
|
///
|
||||||
|
/// inbuxa: read from the environment until `x:FrontEnds` exists (C-4).
|
||||||
|
pub fn front_end_origins() -> Vec<String> {
|
||||||
|
let mut origins = Vec::new();
|
||||||
|
for url in [env("ADMIN_URL"), env("WEBMAIL_URL")].into_iter().flatten() {
|
||||||
|
origins.extend(origin_of(&url));
|
||||||
|
}
|
||||||
|
if let Some(extra) = env("CORS_EXTRA_ORIGINS") {
|
||||||
|
origins.extend(extra.split(',').filter_map(origin_of));
|
||||||
|
}
|
||||||
|
origins.sort();
|
||||||
|
origins.dedup();
|
||||||
|
origins
|
||||||
|
}
|
||||||
|
|
||||||
fn env(name: &str) -> Option<String> {
|
fn env(name: &str) -> Option<String> {
|
||||||
types::branding::env_var(name)
|
types::branding::env_var(name)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -315,6 +352,17 @@ mod tests {
|
|||||||
assert_eq!(clients[1].secret.as_deref(), Some("s3cret"));
|
assert_eq!(clients[1].secret.as_deref(), Some("s3cret"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn origins() {
|
||||||
|
assert_eq!(origin_of("https://Admin.Example.org/"), Some("https://admin.example.org".into()));
|
||||||
|
assert_eq!(origin_of("https://admin.example.org:443/x"), Some("https://admin.example.org".into()));
|
||||||
|
assert_eq!(origin_of("http://localhost:5173"), Some("http://localhost:5173".into()));
|
||||||
|
assert_eq!(origin_of("https://h:8443/app"), Some("https://h:8443".into()));
|
||||||
|
assert_eq!(origin_of("ftp://h"), None);
|
||||||
|
assert_eq!(origin_of("not a url"), None);
|
||||||
|
assert_eq!(origin_of(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn webmail_needs_a_secret() {
|
fn webmail_needs_a_secret() {
|
||||||
let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", "")));
|
let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", "")));
|
||||||
|
|||||||
@@ -739,6 +739,9 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
||||||
|
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
|
||||||
|
|
||||||
// Parse HTTP request
|
// Parse HTTP request
|
||||||
let response = match Box::pin(server.parse_http_request(
|
let response = match Box::pin(server.parse_http_request(
|
||||||
req,
|
req,
|
||||||
@@ -789,6 +792,37 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: echo an allowed front end's origin, never `*`
|
||||||
|
// (contract C-14). Responses that already set their own
|
||||||
|
// CORS headers, such as public discovery metadata, keep
|
||||||
|
// them (C-15).
|
||||||
|
let cors_origins = &server.core.network.http.cors_origins;
|
||||||
|
if !cors_origins.is_empty() {
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.append(
|
||||||
|
hyper::header::VARY,
|
||||||
|
hyper::header::HeaderValue::from_static("Origin"),
|
||||||
|
);
|
||||||
|
if let Some(origin) = origin.filter(|origin| {
|
||||||
|
cors_origins.contains(origin)
|
||||||
|
&& !headers.contains_key(hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||||
|
}) {
|
||||||
|
headers.insert(hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||||
|
headers.insert(
|
||||||
|
hyper::header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||||
|
hyper::header::HeaderValue::from_static(
|
||||||
|
"Authorization, Content-Type, Accept, X-Requested-With",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
hyper::header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||||
|
hyper::header::HeaderValue::from_static(
|
||||||
|
"POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok::<_, hyper::Error>(response)
|
Ok::<_, hyper::Error>(response)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -180,6 +180,21 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
`extraOrigins`. The server echoes the matching origin in
|
`extraOrigins`. The server echoes the matching origin in
|
||||||
`Access-Control-Allow-Origin` with `Vary: Origin`, never `*`, and sends
|
`Access-Control-Allow-Origin` with `Vary: Origin`, never `*`, and sends
|
||||||
nothing for any other origin.
|
nothing for any other origin.
|
||||||
|
|
||||||
|
**Built (interim), 2026-09-18.** Until `x:FrontEnds` exists, the allowed
|
||||||
|
origins come from `INBUXA_ADMIN_URL`, `INBUXA_WEBMAIL_URL` and
|
||||||
|
`INBUXA_CORS_EXTRA_ORIGINS` (comma-separated). Each is reduced to its origin,
|
||||||
|
lowercased and without a default port, and anything that isn't an `http` or
|
||||||
|
`https` URL is skipped. Every response carries `Vary: Origin`. A matching
|
||||||
|
origin gets `Access-Control-Allow-Origin` echoed with the allowed headers and
|
||||||
|
methods, and any other origin gets none. Responses that set their own CORS
|
||||||
|
headers keep them (C-15). With `usePermissiveCors` on, or in bootstrap or
|
||||||
|
recovery mode, upstream's `*` applies instead (C-16). Checked on a local
|
||||||
|
build: in bootstrap mode every origin got `*`; after setup, the admin,
|
||||||
|
webmail and extra origins were echoed on `/jmap/session` and on preflights
|
||||||
|
for `/jmap/`, `/auth/token` and `/api/account`, a foreign origin and a
|
||||||
|
request with no origin got none, and the OAuth discovery document stayed
|
||||||
|
`*`.
|
||||||
- **C-15.** OAuth discovery metadata may stay `*`, since it's public and
|
- **C-15.** OAuth discovery metadata may stay `*`, since it's public and
|
||||||
read-only. The token, revocation, introspection and userinfo endpoints follow
|
read-only. The token, revocation, introspection and userinfo endpoints follow
|
||||||
C-14, so a random web page can't exchange or probe tokens.
|
C-14, so a random web page can't exchange or probe tokens.
|
||||||
|
|||||||
Reference in New Issue
Block a user