diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index 0cb4036..2425268 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -62,6 +62,9 @@ pub struct Http { pub url_https: String, pub allowed_endpoint: IfBlock, 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, pub use_forwarded: bool, pub redirect_root: Option, } @@ -408,6 +411,17 @@ impl Http { #[cfg(not(feature = "dev_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 { http_headers.push(( hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN, @@ -464,6 +478,7 @@ impl Http { http.rate_limit_anonymous }, response_headers: http_headers, + cors_origins, use_forwarded: http.use_x_forwarded, redirect_root: http.redirect_root, } diff --git a/crates/common/src/manager/first_party.rs b/crates/common/src/manager/first_party.rs index 8c3aeda..91cbc1f 100644 --- a/crates/common/src/manager/first_party.rs +++ b/crates/common/src/manager/first_party.rs @@ -142,6 +142,43 @@ fn base_url(bp: &Bootstrap, system: &SystemSettings) -> String { 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 { + let uri = url.trim().parse::().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 { + 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 { types::branding::env_var(name) .ok() @@ -315,6 +352,17 @@ mod tests { 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] fn webmail_needs_a_secret() { let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", ""))); diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 1913f9a..547ba69 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -739,6 +739,9 @@ async fn handle_session(inner: Arc, session: SessionDat ); } + // inbuxa: kept for the cross-origin allowlist (contract C-14) + let origin = req.headers().get(hyper::header::ORIGIN).cloned(); + // Parse HTTP request let response = match Box::pin(server.parse_http_request( req, @@ -789,6 +792,37 @@ async fn handle_session(inner: Arc, 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) } }), diff --git a/docs/spec/contract.md b/docs/spec/contract.md index 7fd6aac..ce5f8b9 100644 --- a/docs/spec/contract.md +++ b/docs/spec/contract.md @@ -180,6 +180,21 @@ Each has an ID, and tests name the IDs they check. `extraOrigins`. The server echoes the matching origin in `Access-Control-Allow-Origin` with `Vary: Origin`, never `*`, and sends 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 read-only. The token, revocation, introspection and userinfo endpoints follow C-14, so a random web page can't exchange or probe tokens.