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:
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-4
@@ -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}
|
||||
|
||||
@@ -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)).
|
||||
Executable
+37
@@ -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'"
|
||||
@@ -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
|
||||
|
||||
+37
-53
@@ -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<string, string>()
|
||||
const registrationChallenges = new ChallengeMap()
|
||||
/** WebAuthn registration challenges for add-credential flow: challenge -> userId */
|
||||
const addCredentialChallenges = new Map<string, string>()
|
||||
const activeChallenges = new Set<string>()
|
||||
const addCredentialChallenges = new ChallengeSet<string>()
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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