# Port Nimara CRM — Security Guidelines **Status:** Mandatory for all development. Every checklist item applies to every layer. **Context:** This system stores personally identifiable information for ultra-high-net-worth individuals — yacht owners, their brokers, legal counsel, and family members. A data breach would be catastrophic for the business and for the individuals involved. Security is not a nice-to-have; it is a hard requirement on every line of code. --- ## 1. Authentication & Session Security ### 1.1 Password Handling - [ ] Passwords hashed with **Argon2id** via Better Auth defaults (never bcrypt, never SHA-\*) - [ ] Minimum password length: **12 characters** (enforced in Zod schema AND server-side) - [ ] Password complexity: at least 1 uppercase, 1 lowercase, 1 digit, 1 special character - [ ] Password set/reset tokens: UUID + HMAC, **48-hour expiry**, single-use, stored hashed in DB - [ ] No password in URL parameters, query strings, or logs - [ ] Rate limit login: **5 failed attempts per email per 15 minutes** → temporary lockout with generic error - [ ] Rate limit password reset requests: **3 per email per hour** ### 1.2 Session Management - [ ] Sessions stored in **PostgreSQL** via Better Auth (not JWT, not localStorage) - [ ] Session cookie: `httpOnly`, `secure`, `sameSite=strict` - [ ] Session duration: **24 hours**, refresh on activity within last 25% - [ ] CSRF token on every state-mutating request (Better Auth provides this) - [ ] Logout destroys server-side session AND clears cookie - [ ] Admin can revoke all sessions for a user ### 1.3 Authentication Responses - [ ] Failed login returns **generic "Invalid credentials"** — never reveal whether email exists - [ ] Password reset for non-existent email returns **same success response** as valid email - [ ] No user enumeration via any endpoint (signup, reset, login) --- ## 2. Authorization & Access Control ### 2.1 Port Scoping (Multi-Tenancy Isolation) - [ ] **Every database query** includes `WHERE port_id = :currentPortId` - [ ] Port ID comes from **authenticated session context**, never from request body/params - [ ] No endpoint allows cross-port data access (except super_admin) - [ ] Port scoping enforced at the **service layer**, not just the API layer - [ ] Drizzle queries use a helper: `withPortScope(query, portId)` or equivalent pattern ### 2.2 RBAC Enforcement - [ ] Permission checks on **every API endpoint** via middleware - [ ] Middleware pattern: `authenticate → extractPort → checkPermission → handler` - [ ] Permission checks use the role's JSON permission map, not hardcoded role names - [ ] Super admin bypasses permission checks but is **still subject to audit logging** - [ ] Port role overrides are applied after global role permissions ### 2.3 Resource-Level Access - [ ] Users can only access records within their assigned port - [ ] Reminder `view_own` vs `view_all` enforced at query level - [ ] File downloads verify the requesting user has access to the parent entity (client, expense, etc.) - [ ] Document signing URLs are **time-limited** and **single-use** per signer --- ## 3. Input Validation & Sanitization ### 3.1 API Input Validation - [ ] **Every API endpoint** validates input with a Zod schema — no exceptions - [ ] Zod schemas validate types, lengths, formats, and allowed values - [ ] Validation happens **before any database query or business logic** - [ ] Validation errors return 400 with field-level error messages (no internal details) - [ ] File upload validation: check MIME type (allowlist), max size (10MB default), filename sanitization ### 3.2 SQL Injection Prevention - [ ] **All queries use Drizzle ORM** parameterized queries — no raw SQL string concatenation - [ ] If raw SQL is ever needed (full-text search, complex aggregations): use `sql.raw()` with `sql.placeholder()` only - [ ] Search inputs passed through `sanitizeSearchTerm()` before inclusion in tsvector queries ### 3.3 XSS Prevention - [ ] Rich text content (TipTap output, email bodies) sanitized with **DOMPurify** before storage AND before rendering - [ ] DOMPurify allowlist: basic formatting tags only (b, i, u, em, strong, p, br, ul, ol, li, a, h1-h6, table, tr, td, th, blockquote, code, pre) - [ ] Links in rich text: `rel="noopener noreferrer"` enforced - [ ] User-generated content rendered via React (auto-escaped by default) — no `dangerouslySetInnerHTML` except for DOMPurify-sanitized content - [ ] File names displayed in UI are always escaped ### 3.4 Path Traversal Prevention - [ ] MinIO object keys constructed from UUIDs, never from user input - [ ] File upload original names stored in DB but **never used as storage paths** - [ ] Storage path format: `{portSlug}/{entity}/{entityId}/{uuid}.{ext}` --- ## 4. Data Protection ### 4.1 Encryption at Rest - [ ] PostgreSQL: disk-level encryption via Docker volume (host responsibility) - [ ] MinIO: **SSE-S3 encryption** enabled on all buckets - [ ] Email account credentials (SMTP/IMAP passwords): encrypted with **AES-256-GCM** before storage in `email_accounts.credentials_enc` - [ ] Google Calendar OAuth tokens: encrypted with **pgcrypto** in `google_calendar_tokens` - [ ] Webhook signing secrets: stored encrypted, decrypted only at delivery time ### 4.2 Encryption in Transit - [ ] **TLS 1.3** on all external connections (nginx terminates) - [ ] Internal Docker network: services communicate over Docker bridge (not exposed to host) - [ ] MinIO connections from app: use HTTPS if MinIO has TLS configured, otherwise Docker-internal HTTP is acceptable - [ ] HSTS header: `max-age=31536000; includeSubDomains` ### 4.3 Sensitive Data Handling - [ ] **Never log** passwords, tokens, API keys, or email credentials - [ ] **Never log** full client PII (names, emails, phone numbers, addresses) — log entity IDs only - [ ] Error responses to clients: generic messages only, no stack traces, no internal paths - [ ] Database error messages caught and replaced with generic 500 responses - [ ] Audit log `old_value`/`new_value` for sensitive fields (email, phone): store hashed or masked versions ### 4.4 PII Minimization - [ ] API responses return only fields the requesting user is authorized to see - [ ] List endpoints return summary fields; detail endpoints return full records - [ ] Export endpoints respect the user's permission level - [ ] Search results do not include archived records unless explicitly requested --- ## 5. Audit Logging ### 5.1 What Must Be Logged - [ ] **Every** create, update, delete, archive, restore operation on every entity - [ ] Client merge operations with full merge details - [ ] Login, logout, failed login attempts - [ ] Permission changes (role assignments, role modifications) - [ ] Document signing events (sent, viewed, signed, completed, expired) - [ ] File uploads and downloads - [ ] Export operations (who exported what, when) - [ ] Admin settings changes - [ ] Bulk operations (what was selected, what action was taken) ### 5.2 Audit Log Format ```typescript { port_id: UUID, // null for system events user_id: string, // null for system-generated action: string, // create, update, delete, archive, restore, merge, login, logout, revert entity_type: string, // client, interest, berth, expense, invoice, file, user, role, etc. entity_id: UUID, field_changed: string, // for updates: which field old_value: JSONB, // previous value (masked for sensitive fields) new_value: JSONB, // new value (masked for sensitive fields) ip_address: string, // from request user_agent: string, // from request metadata: JSONB // extra context (e.g., source: "nocodb_migration") } ``` ### 5.3 Audit Helper Pattern ```typescript // Every service function should use this pattern: async function updateClient(portId: string, clientId: string, data: UpdateClientInput, userId: string) { const existing = await getClient(portId, clientId); const updated = await db.update(clients).set(data).where(...).returning(); await auditLog({ portId, userId, action: 'update', entityType: 'client', entityId: clientId, changes: diffFields(existing, updated), // generates field_changed + old_value + new_value per field }); return updated; } ``` --- ## 6. API Security ### 6.1 Rate Limiting - [ ] **nginx layer:** global rate limits per IP - Auth endpoints: 5 requests/minute - Public API: 30 requests/minute - Authenticated API: 120 requests/minute - File upload: 10 requests/minute - [ ] **Application layer:** Redis sliding window per authenticated user - Configurable per-endpoint (default: 60 requests/minute) - Rate limit headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` ### 6.2 Security Headers (nginx) ```nginx add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header X-XSS-Protection "0" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' wss:; frame-ancestors 'none';" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; ``` ### 6.3 CORS - [ ] Allowed origins: only the CRM domain and the public website domain - [ ] No wildcard (`*`) origins - [ ] Credentials: `true` (for cookie-based auth) - [ ] Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS - [ ] Preflight cache: 1 hour ### 6.4 Request Size Limits - [ ] JSON body: **1MB** max - [ ] File upload: **50MB** max (configurable per endpoint) - [ ] URL length: **2048 characters** max --- ## 7. External Service Security ### 7.1 MinIO - [ ] Access credentials in environment variables only (never in code, never in DB) - [ ] Bucket policy: private by default, pre-signed URLs for client access (15-minute expiry) - [ ] Object key format: no user-controllable path components - [ ] SSE-S3 encryption enabled ### 7.2 Documenso - [ ] API key in environment variable - [ ] Webhook signature verification on every inbound webhook (reject if invalid) - [ ] Signing URLs: generated per-signer, time-limited by Documenso - [ ] Document status changes only accepted via verified webhooks (not client requests) ### 7.3 Google Calendar OAuth - [ ] OAuth tokens encrypted at rest (pgcrypto) - [ ] Refresh tokens rotated on each use - [ ] Token scope: calendar read/write only (minimum scope) - [ ] Revoke tokens on user request or account deactivation ### 7.4 OpenAI Vision API - [ ] API key in environment variable - [ ] Receipt images sent for OCR only — no PII fields sent to OpenAI - [ ] Response validation: don't trust extracted values blindly, show user for confirmation ### 7.5 Frankfurter API - [ ] No API key needed (public) - [ ] Cache responses (24 hours) — don't hit on every request - [ ] Fallback to last cached rate if API is down --- ## 8. Socket.io Security - [ ] Socket connections require valid session cookie (same auth as HTTP) - [ ] Max **10 connections per user** (prevent resource exhaustion) - [ ] Message size limit: **1MB** - [ ] Room authorization: user can only join rooms for their assigned port - [ ] Room naming: `port:{portId}`, `user:{userId}` — portId verified against user's assignments - [ ] No sensitive data in Socket.io payloads — send event type + entity ID, client fetches details via API - [ ] Connection timeout: 30 seconds idle → disconnect --- ## 9. BullMQ Security - [ ] Job data: never include raw credentials or tokens — reference by encrypted DB record ID - [ ] Failed job data: scrub sensitive fields before storing in Redis - [ ] Dead letter queue: alert on accumulation (>10 jobs) via notification system - [ ] Job retry limits: max 3 retries with exponential backoff - [ ] Bull Board admin dashboard: protected by super_admin or director role check --- ## 10. File Upload Security - [ ] MIME type validation: **allowlist only** - Images: `image/jpeg`, `image/png`, `image/gif`, `image/webp` - Documents: `application/pdf`, `application/msword`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document` - Spreadsheets: `application/vnd.ms-excel`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` - [ ] File extension validation: must match MIME type - [ ] Max file size: **50MB** (configurable) - [ ] Filename sanitization: strip path separators, null bytes, unicode control characters - [ ] Storage filename: UUID-based (original name stored in DB only) - [ ] Virus scanning: defer to V2 (document in risk register) - [ ] No direct file serving — always through pre-signed URLs or API proxy with auth check --- ## 11. Environment & Secrets Management - [ ] All secrets in **environment variables** (`.env` files, never committed to git) - [ ] `.env.example` with placeholder values (no real secrets) - [ ] `.gitignore` includes: `.env`, `.env.local`, `.env.production`, `*.pem`, `*.key` - [ ] Docker Compose uses `env_file` directive - [ ] No secrets in Dockerfile build args - [ ] Database connection string: `?sslmode=disable` acceptable for Docker-internal, `?sslmode=require` for any external connection ### Required Environment Variables ``` # Database DATABASE_URL=postgresql://user:pass@postgres:5432/portnimara # Redis REDIS_URL=redis://redis:6379 # Auth BETTER_AUTH_SECRET=<32+ random bytes, base64> CSRF_SECRET=<32+ random bytes, base64> # MinIO MINIO_ENDPOINT=minio:9000 MINIO_ACCESS_KEY= MINIO_SECRET_KEY= # Documenso DOCUMENSO_API_URL=https://documenso.portnimara.com/api/v1 DOCUMENSO_API_KEY= DOCUMENSO_WEBHOOK_SECRET= # Email encryption EMAIL_CREDENTIAL_KEY=<32-byte AES key, hex> # Google OAuth GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= # OpenAI (receipt OCR only) OPENAI_API_KEY= # App APP_URL=https://crm.portnimara.com PUBLIC_SITE_URL=https://portnimara.com NODE_ENV=production ``` --- ## 12. Dependency Security - [ ] `pnpm audit` run in CI — fail build on high/critical vulnerabilities - [ ] Lockfile (`pnpm-lock.yaml`) always committed - [ ] No `*` version ranges in package.json — pin major+minor (`^` is OK, `*` is not) - [ ] Docker base image: `node:20-alpine` (minimal attack surface) - [ ] No unnecessary OS packages in Docker image --- ## 13. Error Handling Security ### What the client sees: ```json // 400 — Validation error { "error": "Validation failed", "details": [{ "field": "email", "message": "Invalid email format" }] } // 401 — Not authenticated { "error": "Authentication required" } // 403 — Not authorized { "error": "Insufficient permissions" } // 404 — Not found (also used for unauthorized access to existing resources) { "error": "Resource not found" } // 429 — Rate limited { "error": "Too many requests", "retryAfter": 60 } // 500 — Server error { "error": "Internal server error" } ``` ### What the client NEVER sees: - Stack traces - Database error messages - Internal file paths - SQL queries - Environment variable values - Other users' IDs or data --- ## 14. Pre-Deployment Security Checklist Before ANY deployment: - [ ] All environment variables set (no defaults that work in production) - [ ] `NODE_ENV=production` - [ ] Debug/dev endpoints disabled - [ ] API docs endpoint (`/api/docs`) disabled in production (or protected behind auth) - [ ] Default/seed admin password changed - [ ] HTTPS enforced (HTTP redirects to HTTPS) - [ ] Database backups configured and tested - [ ] Audit log writing verified (test a mutation, check the log) - [ ] Rate limiting verified (test with rapid requests) - [ ] CORS verified (test from unauthorized origin) - [ ] File upload limits verified (test with oversized file)