# Security — Teknav Summary of security measures across the stack. --- ## Nginx ### HTTP Security Headers - `Strict-Transport-Security` (HSTS) — 1 year (`max-age=31536000; includeSubDomains`) - `Content-Security-Policy` — self-only scripts/styles/fonts; `data:` and `blob:` for inline images; no CDN - `X-Content-Type-Options: nosniff` - `X-Frame-Options: SAMEORIGIN` - `Referrer-Policy: strict-origin-when-cross-origin` - `Permissions-Policy: camera=(), microphone=(), geolocation=()` ### Rate Limiting (tuned to avoid false 429s) | Zone | Rate | Burst | Targets | |---|---|---|---| | `general` | 300 r/min | 200 | SPA routes | | `api` | 300 r/min | 200 | All `/api/*` | | `auth` | 30 r/min | 10 | `/api/v1/auth/login`, `/api/v1/auth/signup` | Fastify applies its own per-IP counters on top (5 login fails / 15 min; 10 signup attempts / hour). ### Blocked Paths - Dotfiles: `.git`, `.env`, `.htaccess`, etc. - Operator credential file: `/ACCOUNTS.md` - Upload directory: blocks `.php/.html/.js/.sh/.py/.env/.conf` execution ### Proxy Hardening - `Server` and `X-Powered-By` headers stripped - `server_tokens off` - Connection timeouts: 30s client, 10s proxy connect, 60s proxy read - `trustProxy` constrained to `['127.0.0.1', '::1']` — only the local nginx proxy is trusted ### Error Handling - 404 → SPA fallback (`/index.html`) for client-side routing - 429 → JSON `{"error":"too_many_requests","retryAfter":60}` - 5xx → JSON `{"error":"service_unavailable"}` - 400/401/403 from `/api/*` pass through as-is (JSON from Fastify) --- ## Backend (Fastify) ### Authentication - **Argon2id** password hashing (m=19 MiB, t=2, p=1) - Login accepts email or `@username`; password verification stays Argon2id-backed - Anti-enumeration: constant-time login regardless of whether the user exists - **Redis-backed sessions** — `tek_sid` cookie (HttpOnly, Secure, SameSite=Lax), 14-day TTL with sliding expiration - **Session rotation** — new session ID issued on login and 2FA completion to prevent session fixation - **Session invalidation on password change** — all sessions for a user are destroyed when their password is updated (via reset, profile, or admin) - **CSRF** — double-submit cookie pattern (`tek_csrf`); required on all mutating routes except `/api/v1/auth/login`, `/api/v1/auth/signup`, `/api/v1/auth/forgot-password`, `/api/v1/auth/reset-password`, `/api/v1/auth/otp/send`, `/api/v1/auth/otp/verify`, `/api/v1/auth/2fa/verify`, `/api/v1/newsletter`, `/api/v1/analytics/events`, `/api/v1/errors`, `/api/v1/jobs`, and OAuth callbacks (`/api/v1/auth/oauth/*`) - **OAuth** — Google and GitHub (authorization-code grant); state token stored in Redis (10-min TTL, one-time use); no client secret exposed to frontend ### Rate Limiting (application layer) - Global Fastify rate limiting on public API traffic (600 requests / IP / min, Redis-backed, per-IP key) - Route-level exceptions for OAuth callbacks, health, and explicit public-mutating endpoints - Login: 5 failures / IP / 15 min → 429 + `Retry-After` - Signup: 10 attempts / IP / hour → 429 + `Retry-After` - Captcha generate: 20 / IP / min - Captcha verify: 10 / IP / min - Newsletter: 3 / IP / hour - 2FA verify: 5 attempts / IP+ticket / 5 min → 429 - Forgot-password: 5 / IP / hour - Reset-password: 10 / IP / hour - Error reporting: 30 / IP / min - Semantic search: 10 / IP / min ### Input Validation - Zod schemas on every route (including admin author CRUD) - Fastify JSON-Schema validation (AJV) on all body/querystring - Content length limits: wiki proposals (500 KB), glossary definitions (50 KB), comments (1000 chars), article body (no upper bound) ### Role-Based Access Control | Role | Access | |---|---| | `reader` | View articles, save, react, manage own profile | | `reviewer` | + Article review queue | | `writer` | + Create/edit own articles | | `editor` | + Edit any article, manage categories/tags/authors/media/newsletter/series | | `admin` | + Delete articles, manage users, activity logs, metrics | ### Engagement Controls - Comment moderation supports bulk approve/delete actions for editors/admins and keeps replies sorted by upvotes. - Comments require an authenticated session; backend strips HTML and limits body length to 1000 chars. - Comment flagging is idempotent per user through a compound unique constraint. - Reading history, notifications, and writer follows are scoped to the current session. - Notification and admin SSE streams require normal cookie auth; nginx disables buffering/compression only for stream paths. ### Credential Storage - 2FA login uses Kavenegar OTP (6-digit code via `crypto.randomInt()`), and the verify endpoint is ticket-based with max 3 attempts. - Staff credentials live in `backend/prisma/credentials.json` (committed, plaintext) - Operator-facing credential sheet lives in `ACCOUNTS.md` (gitignored and nginx-blocked) - `seed.ts` hashes them with Argon2id before DB insert — **never** stored plaintext in Postgres - The three writer-admin accounts use `resetPasswordOnSeed: true` until production rotation is complete - Rotate via the admin panel before go-live - After rotation, users can change their own password from profile settings; existing-password accounts must provide the current password - Public registrations: `reader` role, Argon2id-hashed password (or OAuth — no password stored) - Admin-created users receive a cryptographically random 24-byte password (not a predictable default) --- ## Database (PostgreSQL) - Bind PostgreSQL to localhost only; do not expose it publicly - `passwordHash` — Argon2id only - `User.status` enum — `active`, `suspended`, `pending`; suspended users are rejected at login - OAuth fields (`oauthProvider`, `oauthSubject`) use a composite unique index to prevent duplicate linking - All queries via Prisma ORM — no raw SQL, no `$queryRawUnsafe`, no SQL injection surface --- ## Redis - Bind Redis to localhost only; do not expose it publicly - AOF + RDB persistence - Used for: sessions, CSRF tokens, rate limiting counters, OTP codes, OAuth state, SSE pub/sub, visitor counters --- ## Linux/systemd - Backend runs as the dedicated `teknav` system user, not root - `teknav-backend.service` reads secrets from `/etc/teknav/backend.env` - Upload writes are restricted to `/var/lib/teknav/uploads` - Timers handle scheduled publishing and daily database backups - Redis pub/sub backs SSE notifications and admin live counters; Redis must remain private to localhost - Port 3010 (Fastify) should be firewalled to localhost only; `trustProxy` only trusts `127.0.0.1` and `::1` --- ## Build - After schema changes, also run `cd backend && npm run build` and `cd backend && npm test`. - **Zero CDN** — `scripts/check-no-cdn.mjs` greps `dist/` for external domains; build fails on any match - **Production preflight** — `npm run preflight:prod` checks env templates, nginx SSE/credential protection, required scripts, and generated-engine safety before deployment. - **Strict endpoint tests** — `cd backend && TEST_REQUIRE_SERVER=1 npm test` forces API contract tests against a running backend; without the env var, tests skip endpoint assertions if no backend is reachable. - Run `npm audit` and `cd backend && npm audit` before each deploy - Body size limit: 1 MB global, 25 MB on admin upload routes --- ## Health And Readiness - `GET /api/v1/health` — returns minimal `{ ok: true/false }` for public load balancers and monitors - `GET /api/v1/ready` — returns `{ ok: true }` or HTTP 503 when PostgreSQL or Redis is unavailable - `GET /api/v1/admin/health` — full diagnostics (PostgreSQL connections, Redis memory, feature flags, upload disk, version, uptime); **admin-only**, requires authenticated session - Neither public endpoint exposes credentials, keys, DSNs, or user data. --- ## Pre-Deploy Checklist - [ ] `/etc/teknav/backend.env` configured (`SESSION_SECRET` ≥ 32 chars, strong DB password) - [ ] TLS issued for `www.teknav.ir` and `teknav.ir` - [ ] `nginx/nginx.conf` updated with real `server_name` + cert paths - [ ] OAuth `GOOGLE_CLIENT_ID/SECRET` and/or `GITHUB_CLIENT_ID/SECRET` set if using OAuth - [ ] `OAUTH_CALLBACK_BASE` set to `https://www.teknav.ir` - [ ] SMTP vars set for password reset and email verification - [ ] `SMS_PROVIDER=kavenegar` and `SMS_API_KEY` set for phone OTP - [ ] Credentials in `credentials.json` rotated (via admin UI after first login) - [ ] `ACCOUNTS.md` is not committed and `curl -I https://www.teknav.ir/ACCOUNTS.md` returns 404/403 - [ ] Remove `resetPasswordOnSeed` after rotating writer-admin passwords if future seeds must not reset them - [ ] `npm run preflight:prod` passes, with only accepted migration-readiness warnings - [ ] `npm run build` passes (no CDN refs) - [ ] `cd backend && npm run build` passes - [ ] `cd backend && npm audit` shows 0 vulnerabilities - [ ] `sudo systemctl status teknav-backend` is healthy - [ ] `curl https://www.teknav.ir/api/v1/health` → 200 `{"ok":true}` - [ ] `curl https://www.teknav.ir/api/v1/ready` → 200 when PostgreSQL and Redis are healthy - [ ] Test login at `https://www.teknav.ir/login` - [ ] Test user signup at `https://www.teknav.ir/login` → signup tab - [ ] Test authenticated comment, flag, history, follow, and notification flows - [ ] Test `/api/v1/admin/stream` while logged in as admin/editor - [ ] Verify `curl https://www.teknav.ir/api/v1/admin/health` requires authentication --- ## Known Limitations - **Email/SMS depend on production providers** — verification routes are implemented, but delivery requires configured SMTP and Kavenegar credentials - **`unsafe-inline` on `style-src`** — required by Vite CSS-in-JS injection; harmless (affects styles, not scripts) - **Session invalidation scans all Redis session keys** — efficient for current scale; if session count grows significantly, consider a `passwordChangedAt` field approach instead - **Global rate limit `skipOnError: true`** — if Redis is down, rate limiting is bypassed (fail-open); this is intentional to avoid blocking all traffic on Redis failure