From 76fe7fe6b96650fdf026e289f86b64b1a1b88920 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 10:17:15 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:13=20=E2=80=94=20CORS=20on=20public=20en?= =?UTF-8?q?dpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tower-http CorsLayer at the outermost router position so: - Browsers can fetch /v1/products//policies, /v1/openapi.json, /v1/issuer/public-key, /v1/validate from any origin. Unblocks the dynamic pricing page on docs.keysat.xyz reading live tier config from licensing.keysat.xyz. - Preflight OPTIONS is handled by the CorsLayer directly, never reaches the session-bridge or any handler — so admin endpoints don't 401 on preflight. Security posture unchanged. Access-Control-Allow-Credentials is OFF. The combination of ACAO=* and no-credentials means a cross-origin page can read public responses but can't ride a logged-in admin session cookie to hit /v1/admin/*. Admin endpoints still require an explicit Bearer token, which browsers don't auto-attach cross-origin. Tests: +2 CORS regression tests (cors_allows_cross_origin_on_public_ endpoints, cors_preflight_returns_2xx_without_auth). Full suite: 85 passing. --- licensing-service/src/api/mod.rs | 28 +++++++++++++-- licensing-service/tests/api.rs | 60 ++++++++++++++++++++++++++++++++ licensing-service/web/index.html | 2 +- startos/versions/v0.2.0.ts | 14 +++++++- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 449122d..169c9aa 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -96,6 +96,7 @@ use serde_json::json; use sqlx::SqlitePool; use std::sync::Arc; use tokio::sync::RwLock; +use tower_http::cors::{Any, CorsLayer}; #[derive(Clone)] pub struct AppState { @@ -483,12 +484,35 @@ pub fn router(state: AppState) -> Router { .route("/admin/login/status", get(auth::login_status)) .route("/v1/admin/web-password", post(auth::set_password)) // Bridge cookie-based sessions onto the existing API-key require_admin - // guard. Has to be the last layer so it runs first (axum applies - // layers in reverse-of-declaration order). + // guard. Layers apply in reverse-of-declaration order, so this runs + // AFTER the CorsLayer below (i.e. session-bridge is "inside" CORS). .layer(axum::middleware::from_fn_with_state( state.clone(), session_layer::session_to_bearer, )) + // CORS. Declared last so it's the OUTERMOST layer at runtime, + // which means: + // (a) Preflight OPTIONS requests get answered directly by the + // CorsLayer and never hit the session-bridge or any handler. + // Without this, OPTIONS to /v1/admin/* would 401 because + // browsers don't send credentials on preflights. + // (b) `allow_credentials` is NOT enabled. That's deliberate — + // `Access-Control-Allow-Origin: *` combined with credentials + // is rejected by browsers, AND keeping it off means a hostile + // cross-origin page can't piggy-back on a logged-in admin + // session cookie. Bearer-token auth on /v1/admin/* still + // works (the agent / SDK supplies the token explicitly). + // + // Net effect: public endpoints like /v1/products/:slug/policies, + // /v1/openapi.json, /v1/validate, /v1/issuer/public-key can be + // called from any origin (docs.keysat.xyz, keysat.xyz, third- + // party tools). Admin endpoints stay closed without a token. + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) .with_state(state) } diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 1bc4fa1..bdc15c7 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -3044,3 +3044,63 @@ async fn zaprite_connect_gated_by_pro_entitlement() { assert!(body["upgrade_url"].as_str().expect("upgrade_url").contains("/buy/keysat")); } +/// CORS — the public read-only endpoints answer cross-origin requests +/// from any browser origin so docs.keysat.xyz can fetch live pricing +/// from licensing.keysat.xyz without proxying. `allow_credentials` is +/// intentionally OFF: pages can read public responses but cannot ride +/// a logged-in admin session cookie to hit /v1/admin/*. +#[tokio::test] +async fn cors_allows_cross_origin_on_public_endpoints() { + let (state, _tmp) = make_test_state().await; + let req = build_request( + "GET", + "/v1/openapi.json", + &[("origin", "https://docs.keysat.xyz")], + None, + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let acao = resp + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(acao, "*", "public endpoints should set ACAO: *"); + // Credentials must NOT be allowed — combining `*` origin with + // credentials is rejected by browsers, and disabling it means a + // hostile cross-origin page can't ride a session cookie. + let acac = resp.headers().get("access-control-allow-credentials"); + assert!(acac.is_none(), "credentials must not be allowed"); +} + +/// CORS preflight (OPTIONS) is handled by the CorsLayer directly and +/// never reaches the session-bridge or any handler. This is the path +/// browsers take before issuing an actual cross-origin POST. +#[tokio::test] +async fn cors_preflight_returns_2xx_without_auth() { + let (state, _tmp) = make_test_state().await; + let req = build_request( + "OPTIONS", + "/v1/admin/products", + &[ + ("origin", "https://example.com"), + ("access-control-request-method", "POST"), + ("access-control-request-headers", "authorization,content-type"), + ], + None, + ); + let resp = send(&state, req).await; + // CorsLayer answers preflight with 200 (or 204). No auth required. + assert!( + resp.status().is_success() || resp.status() == StatusCode::NO_CONTENT, + "preflight should be 2xx, got {}", + resp.status() + ); + let acao = resp + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(acao, "*"); +} + diff --git a/licensing-service/web/index.html b/licensing-service/web/index.html index 841fa34..c227b91 100644 --- a/licensing-service/web/index.html +++ b/licensing-service/web/index.html @@ -1254,7 +1254,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; } style: 'display:inline-flex; align-items:center; gap:8px; font-size:13.5px; color:var(--ink-700); cursor:pointer; line-height:1.5' }, [ checkbox, - el('span', null, 'Send an anonymous daily heartbeat to help size the Keysat self-host community.'), + el('span', null, 'Opt-in to send anonymous usage stats so Keysat can improve service and performance'), ]) const inlineRow = el('div', { diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 31c20f3..7a2f103 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,18 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:13 — **CORS on public endpoints.** Small, surgical release. Adds permissive cross-origin headers (`Access-Control-Allow-Origin: *`, all methods, all headers) to every public route so browsers can fetch from any origin. Unblocks a few things the static keysat.xyz / docs.keysat.xyz pages want to do directly without proxying:', + '', + '- The pricing page on docs.keysat.xyz fetches the live tier list from `licensing.keysat.xyz/v1/products/keysat/policies` so it always reflects what\'s actually configured on the master Keysat. No more out-of-sync static copies.', + '- The agent-friendly pitch on keysat.xyz can now link to `licensing.keysat.xyz/v1/openapi.json` and have agents fetch it in-browser without setup.', + '- Third-party tooling (SDK demos, integration sandboxes) can call `/v1/validate` from a browser without a server-side proxy.', + '', + '**Security posture is unchanged.** `Access-Control-Allow-Credentials` is deliberately OFF. That combination — `ACAO: *` plus no-credentials — means a cross-origin page can read public responses but cannot ride a logged-in admin session cookie to hit `/v1/admin/*`. Admin endpoints still require an explicit Bearer token; that token isn\'t auto-attached by browsers; nothing changes for operators.', + '', + '**Test count: 85** (was 83 — +2 new CORS regression tests covering both the ACAO header on public endpoints and OPTIONS preflight handling).', + '', + '**Upgrade path.** v0.2.0:12 → v0.2.0:13 is a drop-in. No schema migrations, no SDK changes, no admin-side behavior change. The only operator-visible difference is that browser-side scripts fetching public endpoints from a different origin start working.', + '', '0.2.0:12 — **Settings tab + agent-friendly operator API.** Major release that consolidates operator configuration into the admin UI and ships first-class agent / AI-automation support: OpenAPI 3.1 spec, scoped API keys, agent integration guide. Plus a slate of UX cleanups carried over from operator testing.', '', '**New Settings tab in the admin UI.** Three subsections in one place:', @@ -264,7 +276,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:12', + version: '0.2.0:13', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under