Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="referrer" content="no-referrer">` 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
Expand Down
79 changes: 79 additions & 0 deletions keycast/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Body>, 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;
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 14 additions & 1 deletion web/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
1 change: 1 addition & 0 deletions web/src/routes/forgot-password/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
</script>

<svelte:head>
<meta name="referrer" content="no-referrer" />
<title>Forgot Password - {BRAND.name}</title>
</svelte:head>

Expand Down
1 change: 1 addition & 0 deletions web/src/routes/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
</script>

<svelte:head>
<meta name="referrer" content="no-referrer" />
<title>Login - {BRAND.name}</title>
</svelte:head>

Expand Down
1 change: 1 addition & 0 deletions web/src/routes/register/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
</script>

<svelte:head>
<meta name="referrer" content="no-referrer" />
<title>Register - {BRAND.name}</title>
</svelte:head>

Expand Down
1 change: 1 addition & 0 deletions web/src/routes/reset-password/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
</script>

<svelte:head>
<meta name="referrer" content="no-referrer" />
<title>Reset Password - {BRAND.name}</title>
</svelte:head>

Expand Down
1 change: 1 addition & 0 deletions web/src/routes/verify-email/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
</script>

<svelte:head>
<meta name="referrer" content="no-referrer" />
<title>Verify Email - {BRAND.name}</title>
</svelte:head>

Expand Down
Loading