v0.2.0:13 — CORS on public endpoints

Adds tower-http CorsLayer at the outermost router position so:

- Browsers can fetch /v1/products/<slug>/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.
This commit is contained in:
Grant
2026-05-11 10:17:15 -05:00
parent 257669092b
commit 76fe7fe6b9
4 changed files with 100 additions and 4 deletions
+26 -2
View File
@@ -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)
}
+60
View File
@@ -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, "*");
}
+1 -1
View File
@@ -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', {
+13 -1
View File
@@ -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