Skip to content

Commit 43f74f9

Browse files
vveerrggclaude
andcommitted
fix: session limits, Secure cookies, credential storage bounds
HIGH fixes: - Session and magic link maps bounded (10k max) with TTL-based cleanup and automatic eviction of expired entries - setCookie() now includes Secure flag (HTTPS-only transport) - WebAuthn credential storage bounded: 10 per user, 50k total users - Added createdAt tracking to SessionToken for TTL enforcement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1edf17b commit 43f74f9

4 files changed

Lines changed: 91 additions & 4 deletions

File tree

src/server/index.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import type {
99
} from '../types/auth';
1010

1111
export class NostrBiometricServer {
12+
/** Maximum number of concurrent sessions before cleanup is triggered */
13+
private static readonly MAX_SESSIONS = 10000;
14+
/** Session time-to-live: 24 hours */
15+
private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
16+
/** Maximum number of concurrent magic links before cleanup is triggered */
17+
private static readonly MAX_MAGIC_LINKS = 10000;
18+
/** Magic link time-to-live: 1 hour */
19+
private static readonly MAGIC_LINK_TTL = 60 * 60 * 1000;
20+
1221
private options: AuthenticationOptions;
1322
private magicLinks: Map<string, MagicLinkPayload>;
1423
private sessions: Map<string, SessionToken>;
@@ -19,11 +28,62 @@ export class NostrBiometricServer {
1928
this.sessions = new Map();
2029
}
2130

31+
/**
32+
* Remove expired sessions to free up space
33+
*/
34+
private cleanupExpiredSessions(): void {
35+
const now = Date.now();
36+
for (const [key, session] of this.sessions) {
37+
if (now - session.createdAt > NostrBiometricServer.SESSION_TTL || now > session.expiresAt) {
38+
this.sessions.delete(key);
39+
}
40+
}
41+
}
42+
43+
/**
44+
* Remove expired magic links to free up space
45+
*/
46+
private cleanupExpiredMagicLinks(): void {
47+
const now = Date.now();
48+
for (const [key, link] of this.magicLinks) {
49+
if (now - link.createdAt > NostrBiometricServer.MAGIC_LINK_TTL || now > link.expiresAt) {
50+
this.magicLinks.delete(key);
51+
}
52+
}
53+
}
54+
55+
/**
56+
* Enforce session map size limit, cleaning up expired entries first
57+
* @throws Error if limit is still exceeded after cleanup
58+
*/
59+
private enforceSessionLimit(): void {
60+
if (this.sessions.size >= NostrBiometricServer.MAX_SESSIONS) {
61+
this.cleanupExpiredSessions();
62+
if (this.sessions.size >= NostrBiometricServer.MAX_SESSIONS) {
63+
throw new Error('Session limit reached');
64+
}
65+
}
66+
}
67+
68+
/**
69+
* Enforce magic link map size limit, cleaning up expired entries first
70+
* @throws Error if limit is still exceeded after cleanup
71+
*/
72+
private enforceMagicLinkLimit(): void {
73+
if (this.magicLinks.size >= NostrBiometricServer.MAX_MAGIC_LINKS) {
74+
this.cleanupExpiredMagicLinks();
75+
if (this.magicLinks.size >= NostrBiometricServer.MAX_MAGIC_LINKS) {
76+
throw new Error('Magic link limit reached');
77+
}
78+
}
79+
}
80+
2281
/**
2382
* Create and send a magic link for the given npub
2483
* @param npub The user's npub to authenticate
2584
*/
2685
async createMagicLink(npub: string): Promise<void> {
86+
this.enforceMagicLinkLimit();
2787
// TODO: Implement magic link creation and sending
2888
}
2989

@@ -51,9 +111,10 @@ export class NostrBiometricServer {
51111
* @param response The WebAuthn response
52112
*/
53113
async verifyWebAuthnAndCreateSession(
54-
npub: string,
114+
npub: string,
55115
response: any
56116
): Promise<SessionToken> {
117+
this.enforceSessionLimit();
57118
// TODO: Implement WebAuthn verification and session creation
58119
throw new Error('Not implemented');
59120
}

src/server/webauthn.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,17 @@ import type {
2222
AuthenticatorTransportFuture,
2323
} from '@simplewebauthn/types';
2424

25+
/**
26+
* NOTE: The in-memory credential store is suitable for development and testing only.
27+
* Production deployments should use a persistent database (e.g., PostgreSQL, SQLite)
28+
* for credential storage to survive server restarts and support horizontal scaling.
29+
*/
2530
export class WebAuthnServer {
31+
/** Maximum number of WebAuthn credentials a single user may register */
32+
private static readonly MAX_CREDENTIALS_PER_USER = 10;
33+
/** Maximum number of distinct users with stored credentials */
34+
private static readonly MAX_TOTAL_USERS = 50000;
35+
2636
private options: WebAuthnOptions;
2737
private challenges: Map<string, { challenge: string; timestamp: number }>;
2838
private credentials: Map<string, StoredCredential[]>;
@@ -125,6 +135,15 @@ export class WebAuthnServer {
125135
const { verified, registrationInfo } = verification;
126136

127137
if (verified && registrationInfo) {
138+
// Enforce credential limits to prevent unbounded memory growth
139+
const userCreds = this.credentials.get(userId) || [];
140+
if (userCreds.length >= WebAuthnServer.MAX_CREDENTIALS_PER_USER) {
141+
throw new Error('Maximum credentials per user reached');
142+
}
143+
if (!this.credentials.has(userId) && this.credentials.size >= WebAuthnServer.MAX_TOTAL_USERS) {
144+
throw new Error('Maximum registered users reached');
145+
}
146+
128147
// Store the credential with its public key for future authentication
129148
const storedCredential: StoredCredential = {
130149
credentialID: Buffer.from(registrationInfo.credentialID).toString('base64url'),
@@ -135,7 +154,7 @@ export class WebAuthnServer {
135154
transports: credential.response.transports as AuthenticatorTransportFuture[] | undefined,
136155
};
137156

138-
this.credentials.set(userId, [storedCredential]);
157+
this.credentials.set(userId, [...userCreds, storedCredential]);
139158
}
140159

141160
return verified;

src/types/auth.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export interface MagicLinkPayload {
8989
export interface SessionToken {
9090
/** The actual session token */
9191
token: string;
92+
/** Timestamp when the session was created */
93+
createdAt: number;
9294
/** Timestamp when the session expires */
9395
expiresAt: number;
9496
/** Public key of the authenticated user */

src/utils/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,21 @@ export function isBrowser(): boolean {
5858

5959
/**
6060
* Set a cookie in the browser
61+
*
62+
* NOTE: HttpOnly cannot be set via document.cookie — it can only be set via
63+
* a server-side Set-Cookie header. Session cookies should be set server-side
64+
* with the HttpOnly flag to prevent client-side script access.
65+
*
6166
* @param name Cookie name
6267
* @param value Cookie value
6368
* @param days Days until expiry
6469
*/
6570
export function setCookie(name: string, value: string, days: number): void {
6671
if (!isBrowser()) return;
67-
72+
6873
const expires = new Date();
6974
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
70-
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
75+
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict;Secure`;
7176
}
7277

7378
/**

0 commit comments

Comments
 (0)