diff --git a/bin/tsa-server/src/main.rs b/bin/tsa-server/src/main.rs index 26b2312..7852f01 100644 --- a/bin/tsa-server/src/main.rs +++ b/bin/tsa-server/src/main.rs @@ -181,6 +181,7 @@ async fn run_serve() { time_source: monitor, max_request_bytes: cfg.max_request_bytes.max(0) as usize, version: env!("CARGO_PKG_VERSION").to_string(), + cors_allowed_origin: cfg.cors_allowed_origin.clone(), }); let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen)) diff --git a/crates/oe-config/src/lib.rs b/crates/oe-config/src/lib.rs index 139369d..f0de564 100644 --- a/crates/oe-config/src/lib.rs +++ b/crates/oe-config/src/lib.rs @@ -91,6 +91,11 @@ pub struct Config { /// pays sont imposés par le profil côté autorité. pub subject_cn: String, pub renew_before: Duration, + /// Origine autorisée à appeler l'API HTTP depuis un navigateur + /// (Access-Control-Allow-Origin). Vide/absent = pas de CORS, aucune + /// requête cross-origin n'est autorisée — comportement par défaut, + /// préservé pour tout déploiement qui n'a pas de démo web tierce. + pub cors_allowed_origin: Option, } impl Config { @@ -169,6 +174,9 @@ impl Config { "OPENEIDAS_RENEW_BEFORE", Duration::from_secs(30 * 24 * 3600), )?, + cors_allowed_origin: env::var("OPENEIDAS_CORS_ALLOWED_ORIGIN") + .ok() + .filter(|s| !s.is_empty()), }) } } @@ -337,6 +345,24 @@ mod tests { assert_eq!(cfg.time_policy, Policy::Enforce); assert_eq!(cfg.accuracy, Duration::from_secs(1)); assert_eq!(cfg.time_max_offset, Duration::from_millis(500)); + assert_eq!(cfg.cors_allowed_origin, None); + clear_env(); + } + + #[test] + #[serial] + fn load_reads_cors_allowed_origin() { + clear_env(); + env::set_var("OPENEIDAS_PIN", "1234"); + env::set_var( + "OPENEIDAS_CORS_ALLOWED_ORIGIN", + "https://demo.open-eidas.eu", + ); + let cfg = Config::load().unwrap(); + assert_eq!( + cfg.cors_allowed_origin.as_deref(), + Some("https://demo.open-eidas.eu") + ); clear_env(); } diff --git a/crates/oe-crosstsa/tests/against_local_server.rs b/crates/oe-crosstsa/tests/against_local_server.rs index b46adff..0397943 100644 --- a/crates/oe-crosstsa/tests/against_local_server.rs +++ b/crates/oe-crosstsa/tests/against_local_server.rs @@ -55,6 +55,7 @@ async fn start_local_tsa() -> String { time_source, max_request_bytes: 64 * 1024, version: "test".to_string(), + cors_allowed_origin: None, }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/oe-httpapi/Cargo.toml b/crates/oe-httpapi/Cargo.toml index 7af72da..f6d00ea 100644 --- a/crates/oe-httpapi/Cargo.toml +++ b/crates/oe-httpapi/Cargo.toml @@ -16,7 +16,7 @@ oe-rfc3161-asn1 = { path = "../oe-rfc3161-asn1" } oe-hsm = { path = "../oe-hsm" } oe-timesource = { path = "../oe-timesource" } axum = "0.8" -tower-http = { version = "0.6", features = ["trace", "limit"] } +tower-http = { version = "0.6", features = ["trace", "limit", "cors"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" diff --git a/crates/oe-httpapi/src/lib.rs b/crates/oe-httpapi/src/lib.rs index f5fb739..ddcbb42 100644 --- a/crates/oe-httpapi/src/lib.rs +++ b/crates/oe-httpapi/src/lib.rs @@ -34,6 +34,11 @@ pub struct Options { pub time_source: Arc, pub max_request_bytes: usize, pub version: String, + /// Origine autorisée en cross-origin (Access-Control-Allow-Origin) pour + /// les routes de lecture/soumission destinées à un navigateur + /// (`/api/v1/timestamp`, `/api/v1/certificate`, `/api/v1/policy`). + /// `None` = pas de couche CORS, comportement par défaut. + pub cors_allowed_origin: Option, } #[derive(Clone)] @@ -49,15 +54,25 @@ pub fn router(opts: Options) -> Router { time_source: opts.time_source, version: Arc::from(opts.version.as_str()), }; - Router::new() + let mut router = Router::new() .route("/tsa", post(handle_rfc3161)) .route("/api/v1/timestamp", post(handle_json)) .route("/api/v1/policy", get(handle_policy)) .route("/api/v1/certificate", get(handle_certificate)) .route("/healthz", get(handle_health)) .layer(DefaultBodyLimit::max(opts.max_request_bytes)) - .layer(tower_http::trace::TraceLayer::new_for_http()) - .with_state(state) + .layer(tower_http::trace::TraceLayer::new_for_http()); + if let Some(origin) = opts.cors_allowed_origin.as_deref() { + if let Ok(origin) = axum::http::HeaderValue::from_str(origin) { + router = router.layer( + tower_http::cors::CorsLayer::new() + .allow_origin(origin) + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_headers([axum::http::header::CONTENT_TYPE]), + ); + } + } + router.with_state(state) } async fn handle_rfc3161( diff --git a/crates/oe-httpapi/tests/end_to_end.rs b/crates/oe-httpapi/tests/end_to_end.rs index d543cf8..7637aa6 100644 --- a/crates/oe-httpapi/tests/end_to_end.rs +++ b/crates/oe-httpapi/tests/end_to_end.rs @@ -65,6 +65,7 @@ async fn serves_a_verifiable_token_over_http() { time_source, max_request_bytes: 64 * 1024, version: "test".to_string(), + cors_allowed_origin: None, }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -153,3 +154,62 @@ async fn serves_a_verifiable_token_over_http() { ); let _ = std::fs::remove_file(&out_path); } + +#[tokio::test] +async fn cors_header_present_only_when_configured() { + let time_source = || { + oe_timesource::Monitor::new(oe_timesource::Options { + policy: oe_timesource::Policy::Disabled, + ..Default::default() + }) + .unwrap() + }; + + // Sans cors_allowed_origin : pas d'en-tête CORS, même pour une origine + // qui enverrait un Origin arbitraire — le navigateur bloquera la lecture + // de la réponse. + let app = oe_httpapi::router(oe_httpapi::Options { + authority: load_authority(), + time_source: time_source(), + max_request_bytes: 64 * 1024, + version: "test".to_string(), + cors_allowed_origin: None, + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let resp = reqwest::Client::new() + .get(format!("http://{addr}/api/v1/policy")) + .header("Origin", "https://demo.open-eidas.eu") + .send() + .await + .unwrap(); + assert!(resp.headers().get("access-control-allow-origin").is_none()); + + // Avec cors_allowed_origin configuré : l'en-tête reflète exactement + // l'origine autorisée, pas un wildcard ni l'origine de la requête. + let app = oe_httpapi::router(oe_httpapi::Options { + authority: load_authority(), + time_source: time_source(), + max_request_bytes: 64 * 1024, + version: "test".to_string(), + cors_allowed_origin: Some("https://demo.open-eidas.eu".to_string()), + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let resp = reqwest::Client::new() + .get(format!("http://{addr}/api/v1/policy")) + .header("Origin", "https://demo.open-eidas.eu") + .send() + .await + .unwrap(); + assert_eq!( + resp.headers().get("access-control-allow-origin").unwrap(), + "https://demo.open-eidas.eu" + ); +} diff --git a/deploy/helm/open-eidas/templates/tsa/deployment.yaml b/deploy/helm/open-eidas/templates/tsa/deployment.yaml index 3c70879..bbc562d 100644 --- a/deploy/helm/open-eidas/templates/tsa/deployment.yaml +++ b/deploy/helm/open-eidas/templates/tsa/deployment.yaml @@ -85,6 +85,10 @@ spec: # sont imposés par le profil côté autorité. - name: OPENEIDAS_SUBJECT_CN value: {{ .Values.tsa.subject.commonName | quote }} + {{- if .Values.tsa.corsAllowedOrigin }} + - name: OPENEIDAS_CORS_ALLOWED_ORIGIN + value: {{ .Values.tsa.corsAllowedOrigin | quote }} + {{- end }} volumeMounts: - name: softhsm mountPath: /var/lib/softhsm/tokens diff --git a/deploy/helm/open-eidas/values-staging.yaml b/deploy/helm/open-eidas/values-staging.yaml index bbc1888..79fe345 100644 --- a/deploy/helm/open-eidas/values-staging.yaml +++ b/deploy/helm/open-eidas/values-staging.yaml @@ -44,6 +44,9 @@ tsa: gateway: enabled: true host: api.staging.open-eidas.eu + # Démo web officielle (open-eidas/demo, GitHub Pages) — seule origine + # autorisée à appeler cette API de staging depuis un navigateur. + corsAllowedOrigin: "https://demo.open-eidas.eu" ca: # Gravée dans les points CRL et AIA des certificats émis : doit correspondre diff --git a/deploy/helm/open-eidas/values.yaml b/deploy/helm/open-eidas/values.yaml index 2b2a07a..fa05a18 100644 --- a/deploy/helm/open-eidas/values.yaml +++ b/deploy/helm/open-eidas/values.yaml @@ -60,6 +60,10 @@ tsa: # PIN du token PKCS#11, généré aléatoirement et conservé dans un Secret # si laissé vide (voir templates/secret-generated.yaml). pin: "" + # Origine autorisée à appeler l'API depuis un navigateur + # (Access-Control-Allow-Origin) — ex. une démo web tierce. Vide = pas de + # CORS, comportement par défaut. + corsAllowedOrigin: "" # Exposition externe via Gateway API (HTTPRoute), pas Ingress : la # terminaison TLS est de la responsabilité du Gateway référencé, pas de ce # chart.