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:
2026-06-01 15:02:10 +02:00
parent b9c908169b
commit e138752dd3
11 changed files with 293 additions and 84 deletions
+12 -1
View File
@@ -6,10 +6,21 @@ DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin) # 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 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 RP_ID=localhost
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost) # Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173 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) # 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 # CORS_ORIGINS=http://localhost:5173
+5 -2
View File
@@ -237,7 +237,7 @@ Gesamten Stack lokal bauen und starten:
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo 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 ## 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. 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 ## Dokumentation
| Dokument | Inhalt | | 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/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/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 | | [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
+7
View File
@@ -3,6 +3,13 @@ server {
server_name localhost; server_name localhost;
client_max_body_size 50M; 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 # Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ { location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html; root /usr/share/nginx/html;
+5 -4
View File
@@ -4,9 +4,9 @@ services:
container_name: daagbox-prod-db container_name: daagbox-prod-db
restart: always restart: always
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
POSTGRES_DB: daagbox POSTGRES_DB: ${POSTGRES_DB:-daagbox}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -23,9 +23,10 @@ services:
restart: always restart: always
environment: environment:
PORT: 5000 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} RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost} ORIGIN: ${ORIGIN:-http://localhost}
TRUST_PROXY: ${TRUST_PROXY:-1}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu} VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
+60
View File
@@ -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=<min. 32 Zeichen, openssl rand -base64 48>
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)).
+37
View File
@@ -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'"
+28
View File
@@ -25,6 +25,24 @@ dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express() const app = express()
const PORT = process.env.PORT || 5000 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( app.use(
helmet({ helmet({
contentSecurityPolicy: false, contentSecurityPolicy: false,
@@ -50,7 +68,17 @@ const apiLimiter = rateLimit({
legacyHeaders: false 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/auth', authLimiter)
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
app.use('/api', apiLimiter) app.use('/api', apiLimiter)
// Mount routes // Mount routes
+37 -53
View File
@@ -14,6 +14,8 @@ import {
setSessionCookie, setSessionCookie,
setSessionTokenCookie setSessionTokenCookie
} from '../session.js' } from '../session.js'
import { ChallengeMap, ChallengeSet } from '../utils/challengeStore.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router() const router = Router()
@@ -21,10 +23,10 @@ const rpName = 'Kapteins Daagbok'
const rpID = process.env.RP_ID || 'localhost' const rpID = process.env.RP_ID || 'localhost'
const origin = process.env.ORIGIN || 'http://localhost:5173' const origin = process.env.ORIGIN || 'http://localhost:5173'
const registrationChallenges = new Map<string, string>() const registrationChallenges = new ChallengeMap()
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */ /** WebAuthn registration challenges for add-credential flow: challenge -> userId */
const addCredentialChallenges = new Map<string, string>() const addCredentialChallenges = new ChallengeSet<string>()
const activeChallenges = new Set<string>() const activeChallenges = new ChallengeSet()
function previewCredentialId(credentialId: string): string { function previewCredentialId(credentialId: string): string {
if (credentialId.length <= 16) return credentialId if (credentialId.length <= 16) return credentialId
@@ -76,7 +78,7 @@ router.post('/register-options', async (req, res) => {
}) })
if (existingUser) { 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') const userID = Buffer.from(username, 'utf8').toString('base64url')
@@ -98,9 +100,8 @@ router.post('/register-options', async (req, res) => {
registrationChallenges.set(username, options.challenge) registrationChallenges.set(username, options.challenge)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating registration options:', error) return sendInternalError(res, error, 'auth/register-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -163,9 +164,8 @@ router.post('/register-verify', async (req, res) => {
setSessionCookie(res, user.id, true) setSessionCookie(res, user.id, true)
return res.json({ verified: true, userId: user.id }) return res.json({ verified: true, userId: user.id })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying registration response:', error) return sendInternalError(res, error, 'auth/register-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -197,9 +197,8 @@ router.post('/login-options', async (req, res) => {
activeChallenges.add(options.challenge) activeChallenges.add(options.challenge)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating authentication options:', error) return sendInternalError(res, error, 'auth/login-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -260,9 +259,8 @@ router.post('/login-verify', async (req, res) => {
encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv, encryptedMasterKeyRecIv: user.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag encryptedMasterKeyRecTag: user.encryptedMasterKeyRecTag
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying authentication response:', error) return sendInternalError(res, error, 'auth/login-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -305,9 +303,8 @@ router.post('/reauth-options', requireUser, async (req: any, res) => {
activeChallenges.add(options.challenge) activeChallenges.add(options.challenge)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating reauth options:', error) return sendInternalError(res, error, 'auth/reauth-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -362,9 +359,8 @@ router.post('/reauth-verify', requireUser, async (req: any, res) => {
} }
return res.json({ verified: true }) return res.json({ verified: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying reauth:', error) return sendInternalError(res, error, 'auth/reauth-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -384,9 +380,8 @@ router.delete('/delete-account', requireReauth, async (req: any, res) => {
clearSessionCookie(res) clearSessionCookie(res)
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error deleting account:', error) return sendInternalError(res, error, 'auth/delete-account')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -415,9 +410,8 @@ router.post('/enroll-prf', requireReauth, async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error enrolling PRF key:', error) return sendInternalError(res, error, 'auth/enroll-prf')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -446,9 +440,8 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error rotating recovery key:', error) return sendInternalError(res, error, 'auth/rotate-recovery')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -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/)') console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS }) return res.json({ ...DEFAULT_APPEARANCE_PREFS })
} }
console.error('Error reading appearance prefs:', error) return sendInternalError(res, error, 'auth/appearance-prefs-get')
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
} }
}) })
@@ -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.' error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
}) })
} }
console.error('Error updating appearance prefs:', error) return sendInternalError(res, error, 'auth/appearance-prefs-put')
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
} }
}) })
@@ -552,9 +541,8 @@ router.get('/profile', requireUser, async (req: any, res) => {
collaborationCount: user._count.collaborations collaborationCount: user._count.collaborations
} }
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching user profile:', error) return sendInternalError(res, error, 'auth/profile')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -591,12 +579,11 @@ router.post('/add-credential-options', requireReauth, async (req: any, res) => {
excludeCredentials excludeCredentials
}) })
addCredentialChallenges.set(options.challenge, req.userId) addCredentialChallenges.add(options.challenge, req.userId)
return res.json(options) return res.json(options)
} catch (error: any) { } catch (error: unknown) {
console.error('Error generating add-credential options:', error) return sendInternalError(res, error, 'auth/add-credential-options')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -670,9 +657,8 @@ router.post('/add-credential-verify', requireReauth, async (req: any, res) => {
transports: credential.transports transports: credential.transports
} }
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error verifying add-credential response:', error) return sendInternalError(res, error, 'auth/add-credential-verify')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -702,9 +688,8 @@ router.patch('/credentials/:id', requireReauth, async (req: any, res) => {
transports: updated.transports transports: updated.transports
} }
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error updating credential label:', error) return sendInternalError(res, error, 'auth/credentials-patch')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -733,9 +718,8 @@ router.delete('/credentials/:id', requireReauth, async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error deleting credential:', error) return sendInternalError(res, error, 'auth/credentials-delete')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
+17 -24
View File
@@ -1,6 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { prisma } from '../db.js' import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js' import { requireUser } from '../middleware/auth.js'
import { sendInternalError } from '../utils/httpErrors.js'
const router = Router() const router = Router()
@@ -39,9 +40,8 @@ router.get('/invite-details', async (req: any, res) => {
encryptedTitle: invitation.logbook.encryptedTitle, encryptedTitle: invitation.logbook.encryptedTitle,
role: invitation.role role: invitation.role
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching invite details:', error) return sendInternalError(res, error, 'collaboration/invite-details')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -90,9 +90,8 @@ router.get('/share-pull', async (req: any, res) => {
photos, photos,
gpsTracks gpsTracks
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error in share-pull:', error) return sendInternalError(res, error, 'collaboration/share-pull')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -159,9 +158,8 @@ router.post('/accept', requireUser, async (req: any, res) => {
logbookId: invitation.logbookId, logbookId: invitation.logbookId,
role: invitation.role role: invitation.role
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error accepting invitation:', error) return sendInternalError(res, error, 'collaboration/accept')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -205,9 +203,8 @@ router.post('/invite', async (req: any, res) => {
token: invitation.token, token: invitation.token,
expiresAt: invitation.expiresAt expiresAt: invitation.expiresAt
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error creating invitation:', error) return sendInternalError(res, error, 'collaboration/invite')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -247,9 +244,8 @@ router.get('/collaborators', async (req: any, res) => {
role: c.role, role: c.role,
createdAt: c.createdAt createdAt: c.createdAt
}))) })))
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching collaborators:', error) return sendInternalError(res, error, 'collaboration/collaborators')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -277,9 +273,8 @@ router.delete('/collaborators/:id', async (req: any, res) => {
}) })
return res.json({ success: true }) return res.json({ success: true })
} catch (error: any) { } catch (error: unknown) {
console.error('Error revoking collaboration:', error) return sendInternalError(res, error, 'collaboration/revoke')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -317,9 +312,8 @@ router.get('/share-link', async (req: any, res) => {
token: invitation ? invitation.token : null, token: invitation ? invitation.token : null,
expiresAt: invitation ? invitation.expiresAt : null expiresAt: invitation ? invitation.expiresAt : null
}) })
} catch (error: any) { } catch (error: unknown) {
console.error('Error fetching share link:', error) return sendInternalError(res, error, 'collaboration/share-link-get')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
@@ -384,9 +378,8 @@ router.post('/share-link', async (req: any, res) => {
return res.json({ success: true }) return res.json({ success: true })
} }
} catch (error: any) { } catch (error: unknown) {
console.error('Error toggling share link:', error) return sendInternalError(res, error, 'collaboration/share-link-post')
return res.status(500).json({ error: error.message || 'Internal server error' })
} }
}) })
+76
View File
@@ -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)
}
}
+9
View File
@@ -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 })
}