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:
// src/middleware.ts — fail-secure on missing config
if (!cookiePassword) {
  throw new Error("WORKOS_COOKIE_PASSWORD is required");
}
If decryption fails or the role is unrecognized, the user defaults to "applicant" — the lowest privilege:
// src/middleware.ts — role validation (inline checks, fail-secure)
if (
  !role ||
  (role !== "super_admin" &&
    role !== "admin" &&
    role !== "editor" &&
    role !== "viewer" &&
    role !== "user" &&
    role !== "applicant")
) {
  session.user.role = "applicant"; // Fail-secure: unknown role gets minimum access
}
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
pnpm --dir apps/terra test -- -t "Auth Guards"
  • 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.
// src/app/actions/team.ts — canEditForm()
const systemRole = await getSystemRole(userId);

if (systemRole === "super_admin" || systemRole === "admin") return true;

const formRole = await getUserFormRole(userId, formId);

// Global viewer caps everything to read-only
if (systemRole === "viewer") return false;

// Global editor can edit assigned forms
if (systemRole === "editor") return true;

return formRole === "owner" || formRole === "editor";
Tests: 38 cases across 4 files
pnpm --dir apps/terra test -- -t "Team Permissions"
pnpm --dir apps/terra test -- -t "Permission System"
pnpm --dir apps/terra test -- -t "submission-management"
pnpm --dir apps/terra test -- -t "system action authorization"
  • 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:
// Layer 1: Sanitize the filename
const safe = cleanFileName("../../evil.exe"); // → "evil.exe"

// Layer 2: Build a safe storage path
const path = buildSafeStoragePath(formId, fileId, safe);
// → "form-123/file-456/evil.exe"

// Layer 3: Final validation (throws on traversal, null bytes, hidden files)
sanitizeStoragePath(path);
Redirect safety prevents open redirects after login:
// src/lib/security.ts — blocks //evil.com, javascript:, data:, encoded variants
getSafeRedirectPath("//evil.com"); // → "/"
getSafeRedirectPath("javascript:alert"); // → "/"

// Defense-in-depth: same-origin check catches anything getSafeRedirectPath misses
const dest = createSafeInternalRedirect(path, baseUrl);
if (dest.origin !== origin) redirect("/"); // CRITICAL log if this ever fires
Tests: 72 cases in src/lib/__tests__/security.test.ts, 15 in files-security.test.ts, 11 in import-security.test.ts
pnpm --dir apps/terra test -- -t "Path Traversal"
pnpm --dir apps/terra test -- -t "Open Redirect"
pnpm --dir apps/terra test -- -t "File Security"
pnpm --dir apps/terra test -- -t "Import Security"
  • 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

// src/lib/rate-limit.ts
const LIMITS = {
  formSubmission: { limit: 30, window: 60, key: "per-IP" },
  statusLookup: { limit: 10, window: 60, key: "per-IP" }, // Stricter: prevents enumeration
  webhook: { limit: 500, window: 60, key: "global" },
};
If Redis (Upstash) is unavailable:
  • Production: Fail closed — deny all requests
  • Development: Fail open — allow all requests
pnpm --dir apps/terra test -- -t "Rate Limiting"
  • 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:
// Headers sent with every webhook delivery
{
  "X-Terra-Signature": hmac("sha256", secret, body),
  "X-Terra-Event": "submission.created",
  "X-Terra-Timestamp": "1704000000"
}

// Receiver verifies: |now - timestamp| <= 5 minutes
Webhook failures are fire-and-forget — they don’t block the submission. The async queue retries up to 5 times with exponential backoff.
pnpm --dir apps/terra test -- -t "Webhook"
pnpm --dir apps/terra test -- -t "webhook-security"
  • 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