diff --git a/docs/SECURITY.md b/docs/SECURITY.md index dd2afbe2..7396723d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -18,6 +18,7 @@ Keycast is a hosted NIP-46 (Nostr remote signer) bunker service. We take securit - ✅ **HTTPS/TLS 1.3** for all API communication - ✅ **NIP-44 encryption** for bunker communication over Nostr relays - ✅ **gRPC with mTLS** for KMS API calls +- ✅ **Web SPA (sensitive routes):** Document responses for `/reset-password`, `/forgot-password`, `/login`, `/register`, and `/verify-email` include **`Referrer-Policy: no-referrer`** (set by the unified server’s static middleware and mirrored in SvelteKit dev via `web/src/hooks.server.ts`). Recovery and verification links may carry tokens or email hints in the query string; this policy avoids leaking those URLs to third parties via the `Referer` header (including when the shared shell loads stylesheets such as Google Fonts from `app.html`). New first-party auth-like routes with URL secrets should be added to **`auth_routes_use_no_referrer` in `keycast/src/main.rs`** and the **`noReferrerAuthPaths` set in `web/src/hooks.server.ts`** together. Auth route components also set `` so client-side navigations tighten policy after the initial load. ### In Memory (During Signing) - ✅ **Immediate zeroization**: Keys zeroed from memory after each signing operation using `zeroize` crate diff --git a/keycast/src/main.rs b/keycast/src/main.rs index d3fe1bd8..f26aa239 100644 --- a/keycast/src/main.rs +++ b/keycast/src/main.rs @@ -342,12 +342,37 @@ async fn assetlinks_json( } } +static REFERRER_POLICY_HEADER: header::HeaderName = + header::HeaderName::from_static("referrer-policy"); + +/// Returns true when the document response for this URL path should include +/// `Referrer-Policy: no-referrer`. +/// +/// Covers first-party routes that may carry recovery or verification tokens in +/// the query string (or handle credentials). The list must stay in sync with +/// `web/src/hooks.server.ts`. +fn auth_routes_use_no_referrer(path: &str) -> bool { + matches!( + path, + "/reset-password" | "/forgot-password" | "/login" | "/register" | "/verify-email" + ) +} + /// Middleware to set Cache-Control headers for static assets /// Browser caching reduces load and improves performance async fn cache_control_middleware(request: Request
, next: Next) -> Response { let path = request.uri().path().to_string(); let mut response = next.run(request).await; + if auth_routes_use_no_referrer(&path) + && !response.headers().contains_key(&REFERRER_POLICY_HEADER) + { + response.headers_mut().insert( + REFERRER_POLICY_HEADER.clone(), + header::HeaderValue::from_static("no-referrer"), + ); + } + // Don't overwrite if route already set Cache-Control if response.headers().contains_key(header::CACHE_CONTROL) { return response; @@ -1315,6 +1340,60 @@ mod tests { )); } + #[test] + fn test_auth_routes_use_no_referrer_exact_paths_only() { + for path in [ + "/reset-password", + "/forgot-password", + "/login", + "/register", + "/verify-email", + ] { + assert!( + auth_routes_use_no_referrer(path), + "expected strict referrer for {path}" + ); + } + assert!(!auth_routes_use_no_referrer("/")); + assert!(!auth_routes_use_no_referrer("/teams")); + assert!(!auth_routes_use_no_referrer("/reset-password/extra")); + assert!(!auth_routes_use_no_referrer("/login/")); + } + + #[tokio::test] + async fn test_cache_control_middleware_sets_referrer_policy_on_auth_paths() { + let app = Router::new() + .route( + "/reset-password", + get(|| async { "html" }).layer(middleware::from_fn(cache_control_middleware)), + ) + .route( + "/", + get(|| async { "html" }).layer(middleware::from_fn(cache_control_middleware)), + ); + + let reset = app + .clone() + .oneshot( + Request::builder() + .uri("/reset-password") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + reset.headers().get(&super::REFERRER_POLICY_HEADER).unwrap(), + "no-referrer" + ); + + let home = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert!(home.headers().get(&super::REFERRER_POLICY_HEADER).is_none()); + } + #[tokio::test] async fn test_request_id_middleware_echoes_trace_header() { let app = Router::new() diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 7da94830..35b507da 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -3,11 +3,24 @@ import { redirect } from "@sveltejs/kit"; const protectedRoutes: string[] = ["/teams", "/keys", "/admin", "/support-admin"]; +/** Must match `auth_routes_use_no_referrer` in `keycast/src/main.rs`. */ +const noReferrerAuthPaths = new Set([ + "/reset-password", + "/forgot-password", + "/login", + "/register", + "/verify-email", +]); + export const handle: Handle = async ({ event, resolve }) => { const hasSession = event.cookies.get("keycast_session") || event.cookies.get("keycastUserPubkey"); if (!hasSession && protectedRoutes.includes(event.url.pathname)) { throw redirect(303, "/"); } - return resolve(event); + const response = await resolve(event); + if (noReferrerAuthPaths.has(event.url.pathname) && !response.headers.has("referrer-policy")) { + response.headers.set("Referrer-Policy", "no-referrer"); + } + return response; }; diff --git a/web/src/routes/forgot-password/+page.svelte b/web/src/routes/forgot-password/+page.svelte index 9e906ef8..8dd3f68b 100644 --- a/web/src/routes/forgot-password/+page.svelte +++ b/web/src/routes/forgot-password/+page.svelte @@ -32,6 +32,7 @@