feat(security): Sprint 1 hardening for production behind NPM
Add trust proxy, WebAuthn challenge TTL, stricter public collaboration rate limits, generic 500 responses, Docker POSTGRES_PASSWORD from env, nginx security headers/CSP, and deployment documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/** WebAuthn challenge TTL — align with sign route. */
|
||||
export const CHALLENGE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface TimedValue<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/** Challenge keyed by arbitrary string (e.g. username) with a string payload. */
|
||||
export class ChallengeMap {
|
||||
private readonly entries = new Map<string, TimedValue<string>>()
|
||||
|
||||
prune(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
this.prune()
|
||||
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
this.prune()
|
||||
const entry = this.entries.get(key)
|
||||
if (!entry) return undefined
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.entries.delete(key)
|
||||
return undefined
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Challenge keyed by challenge id (login/reauth) with optional metadata. */
|
||||
export class ChallengeSet<T = undefined> {
|
||||
private readonly entries = new Map<string, TimedValue<T | undefined>>()
|
||||
|
||||
prune(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
add(key: string, value?: T): void {
|
||||
this.prune()
|
||||
this.entries.set(key, { value, expiresAt: Date.now() + CHALLENGE_TTL_MS })
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
this.prune()
|
||||
const entry = this.entries.get(key)
|
||||
if (!entry) return false
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.entries.delete(key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
if (!this.has(key)) return undefined
|
||||
return this.entries.get(key)?.value as T | undefined
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Response } from 'express'
|
||||
|
||||
const PUBLIC_ERROR = 'Internal server error'
|
||||
|
||||
/** Log full error server-side; never expose stack or Prisma internals to clients. */
|
||||
export function sendInternalError(res: Response, error: unknown, context: string): Response {
|
||||
console.error(`[${context}]`, error)
|
||||
return res.status(500).json({ error: PUBLIC_ERROR })
|
||||
}
|
||||
Reference in New Issue
Block a user