Appearance
Security Overview
Complete inventory of all security features in the ODP API.
Security Posture at a Glance
Transport & Headers
Security Headers (Helmet)
@fastify/helmet is registered on every response.
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | no-referrer |
Strict-Transport-Security | Helmet default |
File: src/server.ts (registerPlugins)
CORS
Configurable per environment. Defaults allow all origins for development convenience — production should restrict.
| Variable | Default | Notes |
|---|---|---|
CORS_ENABLED | true | Master toggle |
CORS_ORIGIN | '*' | Comma-separated list or * |
CORS_METHODS | 'GET,POST,PATCH,DELETE' | |
CORS_ALLOWED_HEADERS | 'Content-Type,Authorization' | |
CORS_EXPOSED_HEADERS | 'Content-Range' | |
CORS_CREDENTIALS | true | |
CORS_MAX_AGE | 18000 | 5 hours |
File: src/server.ts, src/env.ts
Cookie Security
Session cookies use strict security settings:
| Attribute | Value |
|---|---|
httpOnly | true (no JavaScript access) |
secure | true (HTTPS only) |
sameSite | 'lax' (implicit CSRF protection) |
path | '/' |
Configurable via SESSION_COOKIE_ENABLED (default true) and SESSION_COOKIE_NAME (default odp_session_token).
File: src/routes/auth.ts
Database SSL
| Variable | Default | Description |
|---|---|---|
DB_SSL | false | Enable SSL/TLS to database |
DB_SSL_REJECT_UNAUTHORIZED | true | Validate server certificate |
Authentication
Detailed docs: Auth Middleware & Token Strategy
4-Strategy Pipeline
Every request passes through authenticate middleware which tries strategies in order:
| Strategy | Mechanism | Cached | Use Case |
|---|---|---|---|
JWT (with sid) | HS256 + session binding | Session cache (Redis/memory) | Primary auth flow |
| Static token | odp_users.token hash match | No | Long-lived API keys |
| Session token | Direct odp_sessions.token lookup | No | Internal flows |
Sub-token (odp:) | SHA256 hash + timing-safe compare | No | Scoped API access |
Falls back to public accountability if no token or all strategies fail.
Key env vars: SECRET, ACCESS_TOKEN_TTL (15m), REFRESH_TOKEN_TTL (7d)
File: src/middleware/authenticate.ts
Session Management
- Sessions stored in
odp_sessionswith token, user, IP, user-agent, expiry - Session cache: 3-tier (L1 in-memory → L2 Redis → L3 DB) for sub-millisecond lookups
- Token rotation on refresh (old session gets
next_tokenpointer) - Explicit cache invalidation on logout/revocation
- Bulk logout:
deleteAllUserSessionData()clears all sessions for a user
Files: src/auth/session.ts, src/auth/session-cache.ts
Password Security
- Hashing: Argon2 (memory-hard KDF, OWASP recommended)
- Policy enforcement from
odp_settings:- Minimum length (default 8)
- Require uppercase / lowercase / digit / special character
- Custom regex pattern
- Max age in days (password expiration)
password_changed_attracked per user
Files: src/auth/drivers/local.ts, src/utils/password-policy.ts
Two-Factor Authentication (TOTP)
- 6-digit codes, 30-second period, SHA1
- Window: ±1 period for time drift tolerance
- Triggered when
odp_users.tfa_secretis set - SSO users cannot enable TFA (IdP handles MFA)
File: src/services/auth.ts
Login Throttling & Account Lockout
Progressive server-side throttling on failed login:
Attempt 1 → 0s delay
Attempt 2 → 1s delay (LOGIN_THROTTLE_DELAY base)
Attempt 3 → 2s delay
...
Attempt N → min(N × base, LOGIN_THROTTLE_MAX_DELAY)
After auth_login_attempts exceeded → account suspended- Counter stored in cache with 15-minute TTL
- Reset on successful login
LOGIN_THROTTLE_DELAYdefault1s,LOGIN_THROTTLE_MAX_DELAYdefault30sauth_login_attemptsfromodp_settings
File: src/services/auth.ts
WebSocket Authentication
WebSocket clients must authenticate via message after connection:
| Variable | Default | Options |
|---|---|---|
WEBSOCKETS_REST_AUTH | 'handshake' | 'public', 'handshake', 'strict' |
WEBSOCKETS_GRAPHQL_AUTH | 'handshake' | Same |
Heartbeat: configurable period (default 30s) via WEBSOCKETS_HEARTBEAT_PERIOD.
File: src/services/websocket.ts
Access Control
Role-Based Access Control (RBAC)
User → Role → Policies → Permissions (per collection, per action, per field)- Roles have access flags:
admin_access,tech_access,app_access - Policies attach to roles, define permission sets
- Permissions are per-collection, per-action (
read,create,update,delete), with optional field lists and row-level filters tech_accessimpliesadmin_access
Files: src/permissions/validate-access.ts, src/permissions/process-ast.ts
Authorization Middleware
| Middleware | Checks | Protects |
|---|---|---|
requireAdmin | accountability.admin === true | DDL routes, schema, roles, policies, settings |
requireAppAccess | accountability.app || accountability.admin | App panel endpoints |
collectionExists | Collection exists in schema | Item CRUD routes |
Files: src/middleware/require-admin.ts, src/middleware/require-app-access.ts
Impersonation Safeguards
Admins can impersonate other users with strict guardrails:
- Requires
impersonate_accessortech_accessrole flag - Cannot impersonate self
- Cannot nest impersonation (admin already impersonating → denied)
- Cannot impersonate users with
tech_accessorimpersonate_access - Max concurrent sessions:
MAX_IMPERSONATION_SESSIONS(default 3) - Session TTL:
IMPERSONATION_TTL(default 15m) - All start/stop events logged to activity + security events
File: src/services/impersonation.ts
Sub-Token Scoping
Sub-tokens (odp: prefix) enforce least-privilege:
- SCOPE-01:
allowed_rolesintersected with user's current roles at auth time — prevents privilege creep if user roles change after token creation - SCOPE-03:
scopesvalidated against fixed set:['read', 'create', 'update', 'delete'] - SCOPE-04: Sub-tokens cannot create other sub-tokens
- Token format:
odp:+ 64-char hex, stored as SHA256 hash - Timing-safe comparison to prevent timing attacks
Files: src/services/sub-token.ts, src/middleware/authenticate.ts
Runtime Protection
Rate Limiting
Detailed docs: Rate Limiting
Three layers registered at different Fastify hook points:
| Layer | Hook | Key | Default | Purpose |
|---|---|---|---|---|
| Global | onRequest | 'global' | 1000/1s | Server throughput cap |
| Per-IP | onRequest | request.ip | 50/1s | Single-IP abuse |
| Per-User | preHandler | userId || ip | 100/60s | Per-identity abuse |
| per-route | request.ip | 5/60s | Password reset spam |
Backend: RateLimiterMemory (default) or RateLimiterRedis with insuranceLimiter fallback.
SSRF Protection
Detailed docs: SSRF Protection
safeFetch() wraps all outbound HTTP calls:
- Protocol whitelist:
http:andhttps:only - DNS resolution before connect — blocks all RFC1918, loopback, link-local, cloud metadata ranges
- Manual redirect following with per-hop validation (max 5 hops)
- DNS cache (5min TTL) to prevent rebinding attacks
- Configurable allowlist via
SSRF_ALLOWED_HOSTS
Applied to: OAuth2 token exchange, OpenID discovery, Mattermost webhooks.
Input Validation
- Zod schemas for all query parameters and request payloads
- Relational depth limit: max 10 levels (configurable via
QUERYSTRING_MAX_PARSE_DEPTH)- Enforced across: fields, filter, sort, deep queries
- Prevents exponential query expansion
- Query string parsing:
qslibrary witharrayLimit: 100,parameterLimit: 500 - Body limit:
MAX_PAYLOAD_SIZE(default1mb) - Filter operators: strict Zod union — no arbitrary operators accepted
- Sanitized query frozen after parsing to prevent downstream mutation
Files: src/middleware/sanitize-query.ts, src/utils/validate.ts, src/utils/validate-relational-depth.ts
Path Traversal Protection
Local storage driver validates all file paths:
resolved = path.resolve(root, filepath)
if (!resolved.startsWith(root + path.sep)) → throw 'Path traversal detected'Prevents ../../etc/passwd style attacks on file read/write.
File: src/storage/local.ts
SQL Injection Prevention
All database access uses Knex.js parameterized queries. No raw string concatenation in SQL anywhere in the codebase. Advisory locks use integer constants (pg_advisory_xact_lock(9999)), not user input.
Error Sanitization
- Database errors pattern-matched and mapped to safe HTTP status codes
- Stack traces hidden from responses (only visible at
debug/tracelog level) - Constraint violations →
409/400with generic message sanitizeErrorResponse()onSend hook catches errors that bypass the custom error handler- Extension hook
request.errorallows customization
File: src/middleware/error-handler.ts
Monitoring & Audit
Security Event Logging
Detailed docs: Security Events
Dedicated odp_security_events table + pino structured logging for 22 event types:
| Category | Events |
|---|---|
| Authentication | LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, PASSWORD_CHANGED, PASSWORD_RESET_REQUESTED, SESSION_REVOKED |
| Authorization | ROLE_ASSIGNED, ROLE_REMOVED, PERMISSION_CHANGED |
| Tokens | TOKEN_CREATED, TOKEN_REVOKED |
| MFA | MFA_ENABLED, MFA_DISABLED |
| Impersonation | IMPERSONATION_STARTED, IMPERSONATION_STOPPED |
| Infrastructure | CONFIG_CHANGED, SSRF_BLOCKED, RATE_LIMIT_TRIGGERED, AUDIT_CHAIN_BROKEN, DRIFT_DETECTED |
Every event carries: correlation_id (from x-request-id), user_id, ip, user_agent, severity, details (JSONB).
Audit Hash Chain
Detailed docs: Audit Hash Chain
HMAC-SHA256 chain on odp_activity records:
- Each new record hashes its content + the previous record's hash
pg_advisory_xact_lock(9999)serializes writes to prevent race conditions- Scheduled background verification detects tampering
- Emits
AUDIT_CHAIN_BROKEN(severity: critical) on chain break
Configuration Drift Detection
Detailed docs: Drift Detection
Monitors security-relevant config (env vars + DB settings + admin roles):
- Baseline captured at startup, compared on schedule (default: every 5min)
- Event-driven check on settings update
- Cross-node hash comparison via Redis (
odp:drift:{nodeId}) - Emits
DRIFT_DETECTED(severity: warning) with diff details
Sensitive Field Redaction
Sensitive fields are automatically stripped from outputs:
| Field | Table | Behavior |
|---|---|---|
password | odp_users | Stripped from all API responses |
tfa_secret | odp_users | Stripped, replaced by computed tfa_enabled: boolean |
token | odp_users | Stripped from API responses |
Also redacted in: activity log payloads, emitter events, data exports (JSON/CSV/XML/YAML), revision deltas.
File: src/utils/sensitive-fields.ts
Activity Logging
All CRUD mutations logged to odp_activity with: action, user, timestamp, IP, user-agent, collection, item key, origin. Used for audit trail and content version history.
Request Pipeline (Full)
Complete hook registration order in server.ts:
onRequest:
1. Correlation ID (AsyncLocalStorage) ← x-request-id / UUID
2. extractToken ← Header / Cookie / Query
3. rateLimiterGlobal ← 1000 req/s app-wide
4. rateLimiterIP ← 50 req/s per IP
preHandler:
1. authenticate ← JWT → Static → Session → Sub-token → Public
2. rateLimiterPerUser ← 100 req/60s per identity
3. loadSchema ← Attach DB schema
4. sanitizeQuery ← Zod validation + depth check
Route-level preHandlers (as needed):
- requireAdmin
- requireAppAccess
- collectionExists
- validateBatch
onSend:
- cacheResponse
- sanitizeErrorResponse ← Catch escaped errors
Startup schedulers:
- initAuditVerification() ← Cron chain verification
- initDriftDetection() ← Cron drift checkRedis Dependency Matrix
| Feature | Without Redis | With Redis |
|---|---|---|
| Session cache | In-memory (per-instance) | Shared across pods |
| Public role cache | L1 memory + L3 DB | L1 memory + L2 Redis + L3 DB |
| Rate limiter | Per-instance counting | Distributed counting |
| Security events | Full | Full |
| SSRF protection | Full | Full |
| Audit hash chain | Full | Full |
| Drift detection | Local check only | Local + cross-node comparison |
| Login throttle | Per-instance counter | Shared counter |
Environment Variables (Security)
Transport
| Variable | Default | Description |
|---|---|---|
SECRET | (required) | JWT signing key (min 32 chars recommended) |
DB_SSL | false | Database SSL/TLS |
DB_SSL_REJECT_UNAUTHORIZED | true | Validate DB server certificate |
CORS_ENABLED | true | CORS toggle |
CORS_ORIGIN | '*' | Allowed origins |
SESSION_COOKIE_ENABLED | true | Session cookie mode |
SESSION_COOKIE_NAME | 'odp_session_token' | Cookie name |
Auth & Session
| Variable | Default | Description |
|---|---|---|
ACCESS_TOKEN_TTL | '15m' | JWT access token lifetime |
REFRESH_TOKEN_TTL | '7d' | Refresh token / session lifetime |
LOGIN_THROTTLE_DELAY | '1s' | Base delay per failed attempt |
LOGIN_THROTTLE_MAX_DELAY | '30s' | Max throttle delay |
IMPERSONATION_TTL | '15m' | Impersonation session lifetime |
MAX_IMPERSONATION_SESSIONS | 3 | Max concurrent impersonation sessions |
Runtime Protection
| Variable | Default | Description |
|---|---|---|
SECURITY_EVENTS_ENABLED | true | Security event logging |
SSRF_PROTECTION_ENABLED | true | SSRF validation on outbound HTTP |
SSRF_ALLOWED_HOSTS | '' | SSRF allowlist (comma-separated) |
RATE_LIMITER_ENABLED | false | Rate limiter master toggle |
RATE_LIMITER_STORE | 'memory' | 'memory' or 'redis' |
RATE_LIMITER_POINTS | 50 | Per-IP limit |
RATE_LIMITER_DURATION | 1 | Per-IP window (seconds) |
RATE_LIMITER_USER_POINTS | 100 | Per-user limit |
RATE_LIMITER_USER_DURATION | 60 | Per-user window (seconds) |
RATE_LIMITER_LOGIN_POINTS | 10 | Login attempt limit |
RATE_LIMITER_LOGIN_DURATION | 60 | Login window (seconds) |
RATE_LIMITER_GLOBAL_ENABLED | true | Global limiter toggle |
RATE_LIMITER_GLOBAL_POINTS | 1000 | Global limit |
RATE_LIMITER_GLOBAL_DURATION | 1 | Global window (seconds) |
Audit & Monitoring
| Variable | Default | Description |
|---|---|---|
AUDIT_HASH_ENABLED | false | Audit hash chain |
AUDIT_HMAC_KEY | '' | HMAC key (must differ from SECRET) |
AUDIT_VERIFY_SCHEDULE | '0 * * * *' | Verification cron (hourly) |
DRIFT_DETECTION_ENABLED | false | Config drift detection |
DRIFT_CHECK_SCHEDULE | '*/5 * * * *' | Drift check cron (5min) |
Input Limits
| Variable | Default | Description |
|---|---|---|
MAX_PAYLOAD_SIZE | '1mb' | Request body size limit |
QUERYSTRING_MAX_PARSE_DEPTH | 10 | Max query nesting depth |
MAX_RELATIONAL_DEPTH | 10 | Max relational field depth |