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 withoutWORKOS_COOKIE_PASSWORD:
"applicant" — the lowest privilege:
frame-ancestors: 'self' prevents clickjacking.
Tests: 25 cases in src/lib/__tests__/auth-guards.test.ts
What the tests prove
What the tests prove
- Missing or malformed sessions throw
UNAUTHENTICATED- Non-admin roles getUNAUTHORIZEDon admin-only functions -admincannot callsuper_admin-only functions - Nonexistent forms returnNOT_FOUND-withAdminAuth/withFormAuthwrappers block unauthorized actions before they execute
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 globalviewer cannot edit a form even if they hold owner at the form level.
What the tests prove
What the tests prove
- Super admins bypass all form-level checks - Global
vieweroverrides formowner(read-only cap) - Globaleditoroverrides formviewer(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
bulkMarkAsTest). It should return { success: false }. Try removing the last owner of a form — the system should reject it.
Input Validation
Three layers protect file paths:src/lib/__tests__/security.test.ts, 15 in files-security.test.ts, 11 in import-security.test.ts
What the tests prove
What the tests prove
- 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:
.exerejected, 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
- Production: Fail closed — deny all requests
- Development: Fail open — allow all requests
What the tests prove (20+ cases)
What the tests prove (20+ cases)
- 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:What the tests prove (22 cases)
What the tests prove (22 cases)
- 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