# Security

This document lists Devwebs' security controls, the automated tests that prove each one, and the checklist
operators should follow. Tenant isolation has its own document: [TENANCY.md](TENANCY.md).

To report a vulnerability, email the Devwebs Tech Solution security contact listed on your contract. Please don't
open a public issue.

## 1. Controls and their tests

| Requirement | How Devwebs meets it | Proven by |
|---|---|---|
| **Authentication** | scrypt password hashes with a per-user salt. Sessions are a 256-bit random token in an `httpOnly`, `SameSite=Lax` cookie (`Secure` in production); only its SHA-256 is stored. Absolute lifetime is 7 days for customers and 12 hours for platform staff. Revoked server-side on sign-out, password change or reset. | `auth.test.ts`, `security.test.ts` |
| **Brute-force protection** | After 5 failed passwords for one email in 15 minutes, that email is locked for 15 minutes. Unknown emails behave identically, so accounts can't be discovered this way. Separate IP rate limits apply (30 sign-in requests per 15 min). Two-step codes are limited too (5 wrong codes end the pending sign-in). | `security.test.ts` (lockout, 2FA lock); `e2e_security.py` |
| **Two-step sign-in (TOTP)** | RFC 6238 (SHA-1, 30 s, 6 digits, ±1 step). A used code can't be replayed. 10 single-use recovery codes, stored hashed. A user who lost both can be reset only from the server CLI (`2fa:reset`, audited). Turning it on or off and regenerating codes need the password. **Required for platform staff** (`REQUIRE_2FA_FOR_PLATFORM`, on in production): until they set it up, every staff API call except account security returns `403 two_factor_setup_required`. | `security.test.ts` (RFC test vectors, replay, recovery, staff requirement); `e2e_security.py` |
| **Password reset** | Random single-use token; only its hash is stored. Valid for 60 minutes. Requesting one never reveals whether the email exists. Using it signs out every session. | `security.test.ts`; `e2e_security.py` |
| **Authorization** | A role → permission map is checked in the API on every route; the UI only hides what the API already blocks. Platform admin, finance, support, reseller, owner, admin and agent roles. | `auth.test.ts`, `portals.test.ts`, `e2e_roles.py` |
| **Tenant isolation** | The organization comes only from verified membership. Every query is scoped, every background job runs in its row's tenant context, and 250 database triggers reject cross-tenant references. Real-time channels, storage, wallet and WhatsApp calls assert the tenant. | `isolation.test.ts`, `isolation-refs.test.ts`, `tenancy.test.ts` (every route attacked with another tenant's ids) |
| **CSRF** | Every state-changing request must send `X-Requested-With: devwebs`, which cross-site forms can't do. `SameSite=Lax` cookies. | `security.test.ts` |
| **Server-side validation** | Zod schemas on every request body and query; only the parsed, typed values are used. Phone numbers are normalised to E.164. Limits on sizes and counts. | every module's tests |
| **Rate limiting** | API 600/min per IP, webhooks 3000/min, sign-in 30/15 min, public API per key (set on the key). Shared across processes through Redis when `REDIS_URL` is set. | `security.test.ts`, `api.test.ts`, `redis.test.ts` |
| **Encrypted credentials** | WhatsApp tokens, app secrets, Razorpay secrets and webhook signing secrets use AES-256-GCM with `APP_ENCRYPTION_KEY`, which production refuses to start without. Secrets are never returned to the browser (last 4 characters only). API keys and session/reset tokens are stored only as hashes. | `whatsapp.test.ts`, `billing.test.ts`, `phase8.test.ts` |
| **Webhook signatures** | **Meta:** `X-Hub-Signature-256`, HMAC-SHA256 of the raw body with the number's app secret, compared in constant time; each number has its own URL. **Razorpay:** `X-Razorpay-Signature` over the raw body. **Outgoing:** Standard Webhooks signatures. | `whatsapp.test.ts`, `billing.test.ts`, `phase8.test.ts` |
| **Payment integrity** | The wallet is credited only after server-side signature verification and a fetch of the captured payment from Razorpay. Money is integer paise in an append-only ledger with idempotency keys. Reconciliation checks every balance. | `billing.test.ts`, `e2e_billing.py` |
| **Secure uploads** | Size limits. Allow-listed types, checked against the file's actual content (magic bytes), not only the name or declared type. Files are kept outside the web root and served only to members of the same workspace, as downloads with `nosniff`. CSV/XLSX imports are parsed safely, and spreadsheet formulas are neutralised in CSV exports (XLSX cells are written as text, never formulas). | `security.test.ts` (uploads), `importExport.test.ts`, `inbox.test.ts` |
| **SSRF** | Customer webhook targets must be HTTPS. Private, loopback, link-local, metadata, CGNAT and multicast addresses are refused, and the check is repeated at connect time (stops DNS rebinding). No redirects followed. | `phase8.test.ts` |
| **Audit logging** | Every mutation and security event is recorded with actor, organization, IP and target, including failed sign-ins, lockouts, 2FA changes, password changes, sessions revoked, secrets revealed, refunds and admin actions. | `security.test.ts`, module tests |
| **Idempotency** | Payments, ledger entries, campaign sends, webhook events (Meta, Razorpay) and outgoing deliveries are de-duplicated. The public API honours `Idempotency-Key`. | `billing.test.ts`, `campaigns.test.ts`, `api.test.ts` |
| **Queue retries** | Queues are claimed atomically in the database, with exponential backoff and attempt limits per job type, so a failed job never blocks others. A watchdog alerts on stuck workers. | `campaigns.test.ts`, `engine.test.ts`, `phase8.test.ts` |
| **Data retention** | Workspaces choose how long message content is kept (30 days to 2 years, or forever). Content and media are then erased, while the message rows are kept for billing and reports. API logs, webhook events and emails are pruned on platform schedules. | `security.test.ts` (retention) |
| **Consent** | Each contact has an opt-in status with source and timestamps. Marketing templates and campaigns never reach opted-out contacts. STOP-style replies opt out instantly. | `contacts.test.ts`, `campaigns.test.ts`, `whatsapp.test.ts` |
| **Errors without secrets** | Structured logs with a request id. Tokens, passwords, secrets, cookies, signatures and authorization headers are redacted, even inside nested errors. 500 responses carry only a request id, never a stack or SQL. | `security.test.ts` (logging, 500) |
| **Browser hardening** | Content Security Policy (`frame-ancestors 'none'`, scripts limited to self and Razorpay Checkout), `X-Frame-Options: DENY`, `nosniff`, a strict Referrer-Policy, Permissions-Policy and COOP. HSTS and `upgrade-insecure-requests` are added on HTTPS deployments. API responses send `Cache-Control: no-store`. | `security.test.ts`, `e2e_security.py` (also fails on any CSP console error) |
| **Safe configuration** | At startup a preflight refuses production with a missing encryption key, non-HTTPS `PUBLIC_URL`, insecure cookies or SQLite. It warns about simulation modes, missing SMTP or Redis, loosened webhook rules, staff 2FA off, and seeded default passwords. The same list appears in Admin → System health. | `security.test.ts` (configChecks) |

**Test totals at release:** 213 backend tests (SQLite and MySQL), plus a Redis test run against a real Redis.
There are 10 browser end-to-end suites; `e2e_security.py` computes its own TOTP codes independently of the server.

## 2. Operator checklist

- [ ] `APP_ENCRYPTION_KEY` is generated once, stored in a password manager, and never committed.
- [ ] HTTPS only, `COOKIE_SECURE=true`. MySQL and Redis are not reachable from the internet.
- [ ] Every platform staff account uses two-step sign-in, and recovery codes are stored safely. Staff accounts are
      personal (no shared logins); remove leavers in Admin.
- [ ] SMTP is configured, so password resets reach users.
- [ ] Backups run nightly, are stored off-server, and a restore has been tested.
- [ ] Logs are shipped and alert on errors and failed readiness.
- [ ] Node, MySQL, Redis and OS packages are patched. Run `npm audit` on each release (0 known issues at release).
- [ ] Review Admin → Audit log for unexpected admin actions, refunds and secret reveals.

## 3. Known limits

- File storage is local disk. Several servers need shared storage until an object-storage driver is added.
- Sign-in lockout is per email, so an attacker who knows an email can keep that account locked for repeated
  15-minute windows. The user can still reset their password, and staff can see lockouts in the audit log. This
  trade-off is common and deliberate.
- The inbox live stream uses a long-lived connection; proxies must not buffer `/api/inbox/stream` (the supplied
  Caddy and Nginx files handle this).
- Embedded Signup and dedicated per-customer databases are designed for but not enabled.
