For developers
Security controls
Every security control in Harbor, where it lives in the code, and the rules to keep them true when you change things.
The controls
| Control | Where |
|---|---|
Panel CSP: a per-request nonce, strict-dynamic, no inline script without the nonce | apps/app/middleware.ts, lib/security-headers.ts |
| HSTS, nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP | next.config.ts (both apps). Caddy only fills gaps (?Header) |
Website CSP (prerendered, so 'unsafe-inline' for Next's bootstrap; nothing a visitor typed is ever rendered) | apps/web/next.config.ts |
| Passwords: argon2id, 8–128 characters, upper case + digit, not a common password | packages/core/schemas/auth.ts, common-passwords.ts |
| Lockout: 5 failures per account, 20 per address, per 15 minutes | packages/core/sign-in.ts |
| Reset links: 3 per account, 20 per address, per hour; link valid 60 minutes | packages/core/sign-in.ts |
| Sign-ups: 5 per address per hour; email confirmation 48 hours | packages/core/signup.ts |
Two-factor for admin and finance; codes single-use (User.totpLastStep, claimed atomically) | auth.ts, packages/db/auth.ts |
Tenant isolation: withOrg() plus Postgres row-level security | packages/db/tenant.ts, tenant.test.ts |
| Permission on every page and action | lib/access.ts (guardPage, requireAccess), lib/access.test.ts |
| Append-only audit log (app role has no UPDATE/DELETE; a trigger blocks UPDATE for everyone) | migration 20261028120000_security_hardening |
Refused pages and actions write security.access_denied; alerts on /security | lib/access.ts, packages/core/security-alerts.ts |
Uploads judged by their bytes, size-capped, stored under <orgId>/ with generated names | lib/loads/attachments.ts, packages/integrations/storage.ts |
| Carrier email only to the FMCSA address (R3) | packages/core/documents.ts sendTarget() |
Dependencies: pnpm audit clean; security floors in pnpm-workspace.yaml overrides | .github/workflows/security.yml, dependabot.yml |
Production refuses the demo seed and an AUTH_SECRET under 32 characters | packages/db/seed.ts, startup checks |
Keep these true when you change things
Continuous checks
| Check | When |
|---|---|
| Typecheck, lint, build | Every push (ci.yml) |
Unit tests (packages/core and the rest) | Every push |
| End-to-end, accessibility (axe), 360 px, website and panel | Every push |
pnpm audit (fails on high or critical) | Every push and weekly (security.yml) |
| Secret scanning over the full history (gitleaks) | Every push |
| Static analysis (CodeQL) | Every push |
| Software bill of materials (CycloneDX) | Every push |
| Image scan (Trivy) of the website image | Every push |
| Dynamic scan (OWASP ZAP baseline) of the website | Weekly |
| Dependabot updates | Weekly |
Two-factor in end-to-end tests
A code is good once. Wait for a fresh window (msToNextWindow in apps/app/e2e/staff.ts);
never use a future window's code.
Still to do
The panel's own container image and a DAST scan against it, edge rate limiting or a WAF, and two-factor for every role with passkeys and encrypted off-box backups are on the Roadmap.
Reporting a vulnerability
Email the security address in packages/config/brand.ts (brand.email.security).