Skip to main content

Security

Terra uses defense-in-depth: multiple layers of protection with fail-secure defaults.

Authentication

Sessions are encrypted JWT cookies via WorkOS OAuth. The app refuses to start without WORKOS_COOKIE_PASSWORD:
If decryption fails or the role is unrecognized, the user defaults to "applicant" — the lowest privilege:
CSP headers use a per-request nonce (16 random bytes) for script tags. frame-ancestors: 'self' prevents clickjacking. Tests: 25 cases in src/lib/__tests__/auth-guards.test.ts
  • Missing or malformed sessions throw UNAUTHENTICATED - Non-admin roles get UNAUTHORIZED on admin-only functions - admin cannot call super_admin-only functions - Nonexistent forms return NOT_FOUND - withAdminAuth / withFormAuth wrappers block unauthorized actions before they execute
Try to break it: Tamper with the wos-session cookie in the browser — decryption should fail and redirect to /login. Navigate to /admin while logged out — should redirect.

Authorization (RBAC)

Two layers checked together: global role sets the ceiling, form role sets the grant. The critical invariant: a global viewer cannot edit a form even if they hold owner at the form level.
Tests: 38 cases across 4 files
  • Super admins bypass all form-level checks - Global viewer overrides form owner (read-only cap) - Global editor overrides form viewer (can edit assigned forms) - Viewers cannot bulk-modify submissions - Scoped users with no assigned forms see nothing - Non-admins cannot list users, invite admins, or change roles - Last owner of a form cannot be removed or demoted
Try to break it: Log in as a viewer, call a mutation server action directly (e.g., bulkMarkAsTest). It should return { success: false }. Try removing the last owner of a form — the system should reject it.
Migration boundary: canEditForm() has a backwards-compat fallback that returns true when the program_members table doesn’t exist. Review team.ts lines 110-122 to determine if this is still needed.

Input Validation

Three layers protect file paths:
Redirect safety prevents open redirects after login:
Tests: 72 cases in src/lib/__tests__/security.test.ts, 15 in files-security.test.ts, 11 in import-security.test.ts
  • Path traversal: ../, ..\\, null bytes, URL-encoded, double-encoded, mixed slashes, bucket escape
  • Open redirect: //evil.com, javascript:, data:, backslash, encoded variants all return /
  • File upload: .exe rejected, MIME mismatch caught, SVG with <script> detected, PDF with JS detected
  • File operations: Cross-form file access blocked, path traversal on delete blocked, super_admin bypass works
  • Import: User ID comes from session (not request), cannot impersonate another user, max 8 images / 10MB PDF

Rate Limiting

If Redis (Upstash) is unavailable:
  • Production: Fail closed — deny all requests
  • Development: Fail open — allow all requests
  • Rate limit values documented: 30/min submissions, 10/min status, 500/min webhooks - Production fails closed when Redis is down - Development fails open
  • Status lookups are stricter than submissions (enumeration prevention) - Client IP extracted correctly from x-forwarded-for, cf-connecting-ip, x-real-ip

Webhook Security

Outbound webhooks are signed with HMAC-SHA256 and include a timestamp for replay prevention:
Webhook failures are fire-and-forget — they don’t block the submission. The async queue retries up to 5 times with exponential backoff.
  • Auth enforced on all webhook operations (get, save, regenerate, delete) - Invalid URLs rejected - Secrets are 64 chars (32 bytes hex), regeneration produces a different secret - HMAC signatures are deterministic (same input → same output) - Different payloads/secrets produce different signatures - Unsigned webhooks blocked in production - Timestamp freshness enforced (5-min window) - Webhook failure (500, network error) doesn’t throw — just logs

Data Protection

PII encryption and audit logging

Test Coverage

Full test inventory