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)
}