From e138752dd37bf6c3fc16761b71c2f9e063e230a1 Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 1 Jun 2026 15:02:10 +0200 Subject: [PATCH] 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 --- .env.example | 13 ++++- README.md | 7 ++- client/nginx.conf | 7 +++ docker-compose.yml | 9 +-- docs/deployment/npm-security.md | 60 +++++++++++++++++++ scripts/server-patch-env-sprint1.sh | 37 ++++++++++++ server/src/index.ts | 28 +++++++++ server/src/routes/auth.ts | 90 ++++++++++++----------------- server/src/routes/collaboration.ts | 41 ++++++------- server/src/utils/challengeStore.ts | 76 ++++++++++++++++++++++++ server/src/utils/httpErrors.ts | 9 +++ 11 files changed, 293 insertions(+), 84 deletions(-) create mode 100644 docs/deployment/npm-security.md create mode 100755 scripts/server-patch-env-sprint1.sh create mode 100644 server/src/utils/challengeStore.ts create mode 100644 server/src/utils/httpErrors.ts diff --git a/.env.example b/.env.example index eb1f57c..5ea1754 100755 --- a/.env.example +++ b/.env.example @@ -6,10 +6,21 @@ DeepLAPIKey= # Passkey configuration (WebAuthn Relying Party ID and Origin) # For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys) -# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu +# Production (kapteins-daagbok.eu): +# RP_ID=kapteins-daagbok.eu +# ORIGIN=https://kapteins-daagbok.eu RP_ID=localhost # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) ORIGIN=http://localhost:5173 + +# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md +# TRUST_PROXY=172.16.10.10 +# TRUST_PROXY=1 + +# Docker Compose database (required for production deploy) +# POSTGRES_USER=postgres +# POSTGRES_PASSWORD= +# POSTGRES_DB=daagbox # Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login) # CORS_ORIGINS=http://localhost:5173 diff --git a/README.md b/README.md index c33b49f..c3a2b4b 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ Gesamten Stack lokal bauen und starten: Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo -Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen. +Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen. ## Deployment @@ -249,12 +249,15 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen): Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar. -Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. +Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. + +Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md). ## Dokumentation | Dokument | Inhalt | |----------|--------| +| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header | | [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan | | [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan | diff --git a/client/nginx.conf b/client/nginx.conf index 214bdce..037bb53 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -3,6 +3,13 @@ server { server_name localhost; client_max_body_size 50M; + # Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md) + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always; + # Service worker and app shell must revalidate so PWA updates are detected location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ { root /usr/share/nginx/html; diff --git a/docker-compose.yml b/docker-compose.yml index 405999e..a6d6501 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,9 @@ services: container_name: daagbox-prod-db restart: always environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: daagbox + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: ${POSTGRES_DB:-daagbox} volumes: - pgdata:/var/lib/postgresql/data healthcheck: @@ -23,9 +23,10 @@ services: restart: always environment: PORT: 5000 - DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public" + DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox}?schema=public" RP_ID: ${RP_ID:-localhost} ORIGIN: ${ORIGIN:-http://localhost} + TRUST_PROXY: ${TRUST_PROXY:-1} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} diff --git a/docs/deployment/npm-security.md b/docs/deployment/npm-security.md new file mode 100644 index 0000000..f0c4fb3 --- /dev/null +++ b/docs/deployment/npm-security.md @@ -0,0 +1,60 @@ +# Deployment: Nginx Proxy Manager & Security (Sprint 1) + +Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`). + +## NPM Proxy Host + +| Einstellung | Wert | +|-------------|------| +| Domain | `kapteins-daagbok.eu` | +| Scheme | `https` | +| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) | +| Forward Port | `80` (Frontend-Nginx) | +| Websockets | an, falls genutzt | +| Block Common Exploits | an | +| SSL | Let's Encrypt o. ä. | + +### Custom Nginx (Advanced) — empfohlen + +NPM setzt `X-Forwarded-*` in der Regel automatisch. Falls nicht, im Proxy-Host unter **Advanced**: + +```nginx +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Real-IP $remote_addr; +``` + +## Backend-Umgebung (`.env` auf dem Server) + +```env +ORIGIN=https://kapteins-daagbok.eu +RP_ID=kapteins-daagbok.eu +SESSION_SECRET= +TRUST_PROXY=172.16.10.10 +# oder TRUST_PROXY=1 für genau einen Proxy-Hop +``` + +`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash). + +## Security-Header + +- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden. +- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible). + +### Plausible Analytics + +Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`. + +Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz. + +## Nach Deploy prüfen + +1. https://kapteins-daagbok.eu/api/health — `status: ok` +2. Passkey Login / Registrierung +3. DevTools → Application → Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax` +4. Response-Header auf `index.html`: CSP, `X-Frame-Options` +5. Zwei Geräte hinter NAT: unabhängige Rate-Limits (nicht alle als eine IP) + +## Docker Compose + +Keine Default-Passwörter in Produktion: `POSTGRES_PASSWORD` und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)). diff --git a/scripts/server-patch-env-sprint1.sh b/scripts/server-patch-env-sprint1.sh new file mode 100755 index 0000000..4d1d52f --- /dev/null +++ b/scripts/server-patch-env-sprint1.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Patch production .env for Sprint 1 docker-compose (POSTGRES_* + TRUST_PROXY). +# Safe: does not overwrite existing keys. Run on the server in /opt/kapteins-daagbok. +set -euo pipefail + +ENV_FILE="${1:-.env}" + +if [ ! -f "$ENV_FILE" ]; then + echo "Error: $ENV_FILE not found" + exit 1 +fi + +backup="${ENV_FILE}.bak.$(date +%Y%m%d-%H%M%S)" +cp "$ENV_FILE" "$backup" +echo "Backup: $backup" + +ensure_var() { + local key="$1" + local value="$2" + if grep -q "^${key}=" "$ENV_FILE"; then + echo " keep ${key} (already set)" + else + echo "${key}=${value}" >> "$ENV_FILE" + echo " add ${key}" + fi +} + +echo "Patching $ENV_FILE for Sprint 1..." +# Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox) +ensure_var POSTGRES_USER "postgres" +ensure_var POSTGRES_DB "daagbox" +# Default from legacy docker-compose.yml; change only if you use a different DB password +ensure_var POSTGRES_PASSWORD "postgres" +# NPM on 172.16.10.10 → app on this host +ensure_var TRUST_PROXY "172.16.10.10" + +echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'" diff --git a/server/src/index.ts b/server/src/index.ts index a24fc2a..dd7d905 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -25,6 +25,24 @@ dotenv.config({ path: resolve(__dirname, '../.env') }) const app = express() const PORT = process.env.PORT || 5000 +/** Behind Nginx Proxy Manager (172.16.10.10 → app). See docs/deployment/npm-security.md */ +function configureTrustProxy(): void { + const raw = process.env.TRUST_PROXY?.trim() + if (raw === '1' || raw === 'true') { + app.set('trust proxy', 1) + return + } + if (raw) { + app.set('trust proxy', raw) + return + } + if (process.env.NODE_ENV === 'production') { + app.set('trust proxy', 1) + } +} + +configureTrustProxy() + app.use( helmet({ contentSecurityPolicy: false, @@ -50,7 +68,17 @@ const apiLimiter = rateLimit({ legacyHeaders: false }) +/** Unauthenticated collaboration reads — stricter than global API limit */ +const publicCollaborationLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false +}) + app.use('/api/auth', authLimiter) +app.use('/api/collaboration/invite-details', publicCollaborationLimiter) +app.use('/api/collaboration/share-pull', publicCollaborationLimiter) app.use('/api', apiLimiter) // Mount routes diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 151099e..ad2ef18 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -14,6 +14,8 @@ import { setSessionCookie, setSessionTokenCookie } from '../session.js' +import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js' +import { sendInternalError } from '../utils/httpErrors.js' const router = Router() @@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok' const rpID = process.env.RP_ID || 'localhost' const origin = process.env.ORIGIN || 'http://localhost:5173' -const registrationChallenges = new Map() +const registrationChallenges = new ChallengeMap() /** WebAuthn registration challenges for add-credential flow: challenge -> userId */ -const addCredentialChallenges = new Map() -const activeChallenges = new Set() +const addCredentialChallenges = new ChallengeSet() +const activeChallenges = new ChallengeSet() function previewCredentialId(credentialId: string): string { if (credentialId.length <= 16) return credentialId @@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => { }) if (existingUser) { - return res.status(400).json({ error: 'User already exists' }) + return res.status(400).json({ error: 'Could not start registration' }) } const userID = Buffer.from(username, 'utf8').toString('base64url') @@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => { registrationChallenges.set(username, options.challenge) return res.json(options) - } catch (error: any) { - console.error('Error generating registration options:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/register-options') } }) @@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => { setSessionCookie(res, user.id, true) return res.json({ verified: true, userId: user.id }) - } catch (error: any) { - console.error('Error verifying registration response:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/register-verify') } }) @@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => { activeChallenges.add(options.challenge) return res.json(options) - } catch (error: any) { - console.error('Error generating authentication options:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/login-options') } }) @@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => { encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv, encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag }) - } catch (error: any) { - console.error('Error verifying authentication response:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/login-verify') } }) @@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => { activeChallenges.add(options.challenge) return res.json(options) - } catch (error: any) { - console.error('Error generating reauth options:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/reauth-options') } }) @@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => { } return res.json({ verified: true }) - } catch (error: any) { - console.error('Error verifying reauth:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/reauth-verify') } }) @@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => { clearSessionCookie(res) return res.json({ success: true }) - } catch (error: any) { - console.error('Error deleting account:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/delete-account') } }) @@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => { }) return res.json({ success: true }) - } catch (error: any) { - console.error('Error enrolling PRF key:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/enroll-prf') } }) @@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => { }) return res.json({ success: true }) - } catch (error: any) { - console.error('Error rotating recovery key:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/rotate-recovery') } }) @@ -468,9 +461,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => { console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)') return res.json({ ...DEFAULT_APPEARANCE_PREFS }) } - console.error('Error reading appearance prefs:', error) - const message = error instanceof Error ? error.message : 'Internal server error' - return res.status(500).json({ error: message }) + return sendInternalError(res, error, 'auth/appearance-prefs-get') } }) @@ -509,9 +500,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => { error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.' }) } - console.error('Error updating appearance prefs:', error) - const message = error instanceof Error ? error.message : 'Internal server error' - return res.status(500).json({ error: message }) + return sendInternalError(res, error, 'auth/appearance-prefs-put') } }) @@ -552,9 +541,8 @@ router.get('/profile', requireUser, async (req: any, res) => { collaborationCount: user._count.collaborations } }) - } catch (error: any) { - console.error('Error fetching user profile:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/profile') } }) @@ -591,12 +579,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => { excludeCredentials }) - addCredentialChallenges.set(options.challenge, req.userId) + addCredentialChallenges.add(options.challenge, req.userId) return res.json(options) - } catch (error: any) { - console.error('Error generating add-credential options:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/add-credential-options') } }) @@ -670,9 +657,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => { transports: credential.transports } }) - } catch (error: any) { - console.error('Error verifying add-credential response:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/add-credential-verify') } }) @@ -702,9 +688,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => { transports: updated.transports } }) - } catch (error: any) { - console.error('Error updating credential label:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/credentials-patch') } }) @@ -733,9 +718,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => { }) return res.json({ success: true }) - } catch (error: any) { - console.error('Error deleting credential:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/credentials-delete') } }) diff --git a/server/src/routes/collaboration.ts b/server/src/routes/collaboration.ts index d1add2a..0b90bb8 100644 --- a/server/src/routes/collaboration.ts +++ b/server/src/routes/collaboration.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { prisma } from '../db.js' import { requireUser } from '../middleware/auth.js' +import { sendInternalError } from '../utils/httpErrors.js' const router = Router() @@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => { encryptedTitle: invitation.logbook.encryptedTitle, role: invitation.role }) - } catch (error: any) { - console.error('Error fetching invite details:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/invite-details') } }) @@ -90,9 +90,8 @@ router.get('/share-pull', async (req: any, res) => { photos, gpsTracks }) - } catch (error: any) { - console.error('Error in share-pull:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/share-pull') } }) @@ -159,9 +158,8 @@ router.post('/accept', requireUser, async (req: any, res) => { logbookId: invitation.logbookId, role: invitation.role }) - } catch (error: any) { - console.error('Error accepting invitation:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/accept') } }) @@ -205,9 +203,8 @@ router.post('/invite', async (req: any, res) => { token: invitation.token, expiresAt: invitation.expiresAt }) - } catch (error: any) { - console.error('Error creating invitation:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/invite') } }) @@ -247,9 +244,8 @@ router.get('/collaborators', async (req: any, res) => { role: c.role, createdAt: c.createdAt }))) - } catch (error: any) { - console.error('Error fetching collaborators:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/collaborators') } }) @@ -277,9 +273,8 @@ router.delete('/collaborators/:id', async (req: any, res) => { }) return res.json({ success: true }) - } catch (error: any) { - console.error('Error revoking collaboration:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/revoke') } }) @@ -317,9 +312,8 @@ router.get('/share-link', async (req: any, res) => { token: invitation ? invitation.token : null, expiresAt: invitation ? invitation.expiresAt : null }) - } catch (error: any) { - console.error('Error fetching share link:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/share-link-get') } }) @@ -384,9 +378,8 @@ router.post('/share-link', async (req: any, res) => { return res.json({ success: true }) } - } catch (error: any) { - console.error('Error toggling share link:', error) - return res.status(500).json({ error: error.message || 'Internal server error' }) + } catch (error: unknown) { + return sendInternalError(res, error, 'collaboration/share-link-post') } }) diff --git a/server/src/utils/challengeStore.ts b/server/src/utils/challengeStore.ts new file mode 100644 index 0000000..98c5435 --- /dev/null +++ b/server/src/utils/challengeStore.ts @@ -0,0 +1,76 @@ +/** WebAuthn challenge TTL — align with sign route. */ +export const CHALLENGE_TTL_MS = 5 * 60 * 1000 + +interface TimedValue { + value: T + expiresAt: number +} + +/** Challenge keyed by arbitrary string (e.g. username) with a string payload. */ +export class ChallengeMap { + private readonly entries = new Map>() + + 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 { + private readonly entries = new Map>() + + 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) + } +} diff --git a/server/src/utils/httpErrors.ts b/server/src/utils/httpErrors.ts new file mode 100644 index 0000000..a7f5302 --- /dev/null +++ b/server/src/utils/httpErrors.ts @@ -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 }) +}