Files
teknav-bak/app/SECURITY.md
T
arsam e9cd12dda6 Import remaining project documentation and reference notes
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:33:04 +03:30

187 lines
9.9 KiB
Markdown

# 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