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:
@@ -96,6 +96,7 @@ use serde_json::json;
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -483,12 +484,35 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/admin/login/status", get(auth::login_status))
|
.route("/admin/login/status", get(auth::login_status))
|
||||||
.route("/v1/admin/web-password", post(auth::set_password))
|
.route("/v1/admin/web-password", post(auth::set_password))
|
||||||
// Bridge cookie-based sessions onto the existing API-key require_admin
|
// Bridge cookie-based sessions onto the existing API-key require_admin
|
||||||
// guard. Has to be the last layer so it runs first (axum applies
|
// guard. Layers apply in reverse-of-declaration order, so this runs
|
||||||
// layers in reverse-of-declaration order).
|
// AFTER the CorsLayer below (i.e. session-bridge is "inside" CORS).
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
session_layer::session_to_bearer,
|
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)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3044,3 +3044,63 @@ async fn zaprite_connect_gated_by_pro_entitlement() {
|
|||||||
assert!(body["upgrade_url"].as_str().expect("upgrade_url").contains("/buy/keysat"));
|
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, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
style: 'display:inline-flex; align-items:center; gap:8px; font-size:13.5px; color:var(--ink-700); cursor:pointer; line-height:1.5'
|
||||||
}, [
|
}, [
|
||||||
checkbox,
|
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', {
|
const inlineRow = el('div', {
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
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.',
|
'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:',
|
'**New Settings tab in the admin UI.** Three subsections in one place:',
|
||||||
@@ -264,7 +276,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:12',
|
version: '0.2.0:13',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user