Files
TX/threat_model.md
T
Riyadh 0cee551f60 Update project documentation and code comments to remove platform-specific references
Refactor documentation files and code comments to remove references to Replit, specific task numbers, and other platform-specific identifiers.
2026-05-14 06:23:49 +00:00

183 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Threat Model — Tx OS
**Version:** 1.0
**Companion document:** `SECURITY_REVIEW.md` (manual review findings, kept internally).
> This document captures Tx OS's assets, trust boundaries, threats (STRIDE-style), and the security guarantees the system commits to upholding. It is the design-level companion to the per-finding manual review and is intended to outlive any individual fix task.
---
## 1. System overview
Tx OS is an internal "office OS" for a single-tenant organization. It is deployed as a self-hosted container stack (Express API + Postgres + S3-compatible object storage such as MinIO) with a React/Vite SPA, a small mobile-friendly UI, and a real-time notification layer over Socket.IO. The app is bilingual (Arabic / English), and the workforce uses it to:
- Order internal services (e.g. coffee, printing).
- Manage notes, including shared-folder collaboration with checklist items.
- Schedule and track Executive Meetings, including PDF exports and an alert popup.
- Administer apps, roles, permissions, groups, and user accounts.
The app is **single-tenant** — there is no multi-tenant isolation requirement; all authenticated users belong to the same organization. The core authorization model is per-user roles + per-user groups, with permissions attached to roles and visibility attached to apps.
---
## 2. Assets
| Asset | Sensitivity | Where it lives | Why it matters |
|---|---|---|---|
| User credentials (`users.password_hash`) | Critical | Postgres `users` table | Compromise = account takeover for every user. |
| Session ids | Critical | Postgres `user_sessions` (connect-pg-simple) + browser cookie | Session hijack = full impersonation. |
| Password reset tokens (hashed) | High | `password_reset_tokens` table | One-shot account takeover if intercepted. |
| Executive meeting content (titles, attendees, notes, location, URL) | High | `executive_meetings*` tables | Org-confidential. Attendee lists reveal org structure. |
| Generated PDF archives | High | Object Storage `PRIVATE_OBJECT_DIR/uploads/<uuid>` | Same content as above, persisted. |
| Notes content (titles, body, checklist, replies) | High | `notes*` tables | User-private; some shared via folders. |
| User directory (name, email, avatar) | Medium | `users` table | Phishing fuel. |
| Service order history | Medium | `service_orders` table | Personal consumption record. |
| Audit logs (`audit_logs`, role/permission audit) | Medium | Postgres | Tamper-evidence; integrity matters more than confidentiality. |
| App / role / permission configuration | Medium | `apps`, `roles`, `permissions`, `app_permissions` | Mis-edit = privilege change. |
| Brand logo + uploaded service images / avatars | Low | Object Storage | Public-ish; logo is rendered into PDFs. |
---
## 3. Trust boundaries
```
┌─────────────────────────────────────────────────────────────────────┐
│ Public internet │
│ │
│ ┌────────────┐ HTTPS ┌──────────────────────────────────┐ │
│ │ Browser │ ─────────▶ │ TLS-terminating reverse proxy │ │
│ │ (SPA) │ │ (Caddy / Nginx / Traefik) │ │
│ └────────────┘ └──────────────┬──────────────────┘ │
│ │ │
└────────────────────────────────────────────┼───────────────────────┘
│ HTTP (proxied)
┌──────────────────────────────┐
│ Express API server │
│ (artifacts/api-server) │
│ • express-session (Postgres)│
│ • Socket.IO │
│ • Drizzle ORM │
└────────────┬─────────────────┘
┌──────────────────────────────┼──────────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────────┐ ┌───────────────┐
│ PostgreSQL │ │ MinIO / S3 │ │ Local FS │
│ (container) │ │ (signed URLs, │ │ (dev fallback │
│ │ │ private network)│ │ driver only) │
└───────────────┘ └──────────────────┘ └───────────────┘
```
**Trust boundaries (numbered):**
1. **Browser ↔ Edge proxy.** TLS-terminated by an operator-controlled reverse proxy (Caddy / Nginx / Traefik). The SPA is the only first-party client.
2. **Edge ↔ API server.** Loopback or private docker network. The API server treats `X-Forwarded-For` as trusted (`app.set("trust proxy", 1)`); the operator MUST ensure only the edge proxy can reach the API port.
3. **API server ↔ Postgres.** Connection over the in-cluster network; credentials in `DATABASE_URL`. Drizzle ORM is the only query path.
4. **API server ↔ Object Storage.** Signed-URL pattern. In production the API server signs S3 PUT URLs against a self-hosted MinIO endpoint over the private docker network; in local development without S3 the API signs HMAC tokens validated by its own `/api/storage/_local/upload` route, persisting to the local filesystem under `./storage/`. All upload URLs are short-lived (15 min) and target `randomUUID()` paths under `PRIVATE_OBJECT_DIR`.
5. **API server ↔ Socket.IO clients.** Same origin as the API server. Auth shared via session cookie; every socket joins exactly its `user:${userId}` room.
---
## 4. Security guarantees the system must uphold
These are the invariants every change must preserve. A regression on any one of them is, by default, at least High severity.
- **G1 — Authentication required.** Every `/api/*` endpoint other than the intentionally-public set requires a valid session. The intentionally-public set today is: `/api/auth/login`, `/api/auth/register`, `/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-password/verify`, `/api/health`, `/api/settings` (see MR-L5 — recommended to remove from this list), and `/api/storage/public-objects/*`. Adding any new endpoint to this list must be a deliberate, reviewed change.
- **G2 — Server-side authorization.** UI hiding is *not* a permission boundary. Every mutating endpoint and every read of non-public data must enforce its permission server-side, even if the SPA hides the control.
- **G3 — Cross-user isolation.** A user can never read or modify another user's notes, orders, password, settings, sessions, or notifications unless an explicit share / group / admin permission grants it.
- **G4 — Built-in app routes are immutable.** Slugs in `BUILTIN_APP_SLUGS` cannot have their `route` or `slug` mutated, even by an admin, because the SPA hardcodes those routes.
- **G5 — Sensitive HTML is sanitized.** Every user-supplied string that enters an HTML or PDF rendering path passes through `htmlToSafeHtml`/`safeHtml` (`sanitize-html` + DOMPurify) with the established allow-list.
- **G6 — Secrets never leave the server.** No `.env` value is sent to the browser. `SESSION_SECRET` is required in production (`app.ts:64`).
- **G7 — Database access via parameterized queries only.** Drizzle ORM template tags and the column-name allow-list in `executive-meetings.ts:109` are the only `sql\`\`` consumers; no string-spliced SQL is permitted.
- **G8 — Loopback-only services stay loopback.** Cross-service traffic between the API container and MinIO / Postgres rides the private docker network only; no service should be exposed on the public interface other than the nginx `web` container (which is itself fronted by an operator-managed TLS-terminating reverse proxy).
---
## 5. STRIDE threats
For each STRIDE category, the table lists the most consequential threats, the relevant assets, current mitigations, and any open gaps. References like *(MR-H1)* point to entries in the internal security review.
### 5.1 Spoofing
| # | Threat | Mitigation today | Gap |
|---|---|---|---|
| S1 | Login as another user via stolen password | Bcrypt password hashing; session cookie `httpOnly` | No rate limit on `/auth/login` *(MR-H3)*; bcrypt cost 10 instead of 12 *(MR-L2)*; account enumeration on register *(MR-L3)* |
| S2 | Hijack victim's session by fixing a session id | `connect.sid`, `httpOnly`, `secure` (prod), `sameSite=lax`, `trust proxy=1` | No `req.session.regenerate` after login *(MR-M2)* |
| S3 | Spoof another user over the realtime channel | Server-side `userId` from session; per-user rooms | None — the server defines no client-originated socket events |
| S4 | Spoof the Object Storage endpoint | `S3_ENDPOINT` env is operator-controlled and resolved over the private docker network only; signed-URL responses include the bucket/key the API issued | None |
### 5.2 Tampering
| # | Threat | Mitigation today | Gap |
|---|---|---|---|
| T1 | Cross-site request forces state change as the victim | `sameSite=lax` cookie, custom `Content-Type: application/json` for most writes | No CSRF token; `lax` permits top-level GET/POST navigation *(MR-M1)* |
| T2 | Mutate another user's note/order/meeting | Per-route permission checks; tenant-scoping via `userId`/`folderId` lookups | OK — no mass-assignment issues found |
| T3 | Edit a built-in app's route to break navigation | Built-in route lock with regression tests | OK — `apps-builtin-route-lock.test.mjs` covers it |
| T4 | Pollute the audit log with fake events | `app.opens` audit row is recorded for any caller | `POST /apps/:id/open` doesn't gate on visibility *(MR-M5)* |
| T5 | Pollute the PDF archive list with attacker-chosen `filePath` | `requireExecutiveAccess` only | Any executive role can write arbitrary paths *(MR-H2)* |
### 5.3 Repudiation
| # | Threat | Mitigation today | Gap |
|---|---|---|---|
| R1 | Admin denies having changed a permission | `permission-audit.ts` records role/permission changes | OK |
| R2 | User denies sending a note | `notes` table has `createdAt` + sender; reply chain timestamped | OK |
| R3 | App-open audit is unreliable due to T4 above | — | Same fix as T4 *(MR-M5)* |
### 5.4 Information disclosure
| # | Threat | Mitigation today | Gap |
|---|---|---|---|
| I1 | Read another user's PDF or avatar by guessing object UUID | Object paths are random UUIDs | `/api/storage/objects/*` only requires auth, not per-object access *(MR-H1)* |
| I2 | Enumerate executive meeting dates as an unprivileged user | Per-meeting fetches require `executive_access` | The realtime `executive_meetings_changed` and `executive_meeting_notifications_changed` events are global broadcasts *(MR-L1)* |
| I3 | Harvest the org's user directory | `requireAuth` gate | No permission gate; entire directory is visible to every user *(MR-M4)* |
| I4 | Leak DB schema via verbose error messages | Most routes return coded short strings | `executive-meetings.ts` sites echo `Error.message` *(MR-M6)* |
| I5 | Leak SPA bundle internals via missing CSP | TLS only | No `helmet` / CSP *(MR-M3)* |
| I6 | Leak public flags via `/api/settings` | — | Endpoint is unauthenticated *(MR-L5)* |
### 5.5 Denial of service
| # | Threat | Mitigation today | Gap |
|---|---|---|---|
| D1 | Brute-force `/auth/login` until success | bcrypt slowdown only | No rate limit *(MR-H3)* |
| D2 | Flood `/auth/forgot-password` to fill table + send mail | None | No rate limit *(MR-H3)* |
| D3 | Open thousands of sockets to exhaust file descriptors | `sessionMiddleware` gate at handshake | No per-user / per-IP cap *(MR-L4)* |
| D4 | Send giant JSON bodies to slow parse | `express.json()` default 100 KB | No tighter cap *(MR-L7)* |
| D5 | Trigger expensive PDF render in a loop | `requireExecutiveAccess` only | No per-user PDF render rate limit (latent) |
### 5.6 Elevation of privilege
| # | Threat | Mitigation today | Gap |
|---|---|---|---|
| E1 | Low-priv user reads admin-only meeting PDFs | — | *(MR-H1, MR-H2)* — currently exploitable via storage download |
| E2 | Low-priv user changes their own role | `roles.ts` is `requireAdmin` | OK |
| E3 | User edits another user's password | `users.ts` password change is `requireAdmin` or self-only | OK |
| E4 | Bypass built-in route lock by renaming slug then route | Both fields locked together with regression tests | OK |
| E5 | Smuggle XSS into PDF or note rendering | `htmlToSafeHtml` + DOMPurify allow-list | OK at HEAD, but no CSP backstop *(MR-M3)* |
---
## 6. What is explicitly out of scope
- **Multi-tenant isolation.** Tx OS is single-tenant. There is no per-tenant data partition.
- **Data residency.** Production data is stored wherever the operator deploys the docker-compose stack (private VPS or on-prem). The operator chooses the region and is responsible for any regulatory commitments.
- **Penetration testing of the live deployment.** This document is design-level only.
- **Compliance frameworks** (SOC 2 / ISO 27001). Not in scope today; the threat model is structured so the most material gaps would map cleanly into a future compliance effort if one is opened.
---
## 7. Open recommendations (prioritized for follow-up)
The detailed remediation text lives in the internal security review (per-finding) and the dependency-scan report (dependency batches). The order below is the recommended fix sequence based on risk × effort.
1. **MR-H1 + MR-H2 + MR-M7** — wire up object-level authorization for `/api/storage/objects/*` and constrain the PDF archive write endpoint. *(Highest user impact; one focused change to `routes/storage.ts` plus a small change to `pdf-archives` write.)*
2. **MR-H3 + MR-L4** — add `express-rate-limit` on auth endpoints and a per-user socket cap. *(Small library change, big DoS / brute-force win.)*
3. **MR-M1 + MR-M2** — CSRF posture (move session cookie to `sameSite=strict` if no third-party deep-links, otherwise add a CSRF token middleware) + `req.session.regenerate` on login.
4. **MR-M3** — add `helmet()` and a basic CSP for the SPA bundle.
5. **MR-M4 + MR-M5 + MR-M6 + MR-L5** — tighten the small set of over-exposed read endpoints (directory, settings) and audit-log write paths.
6. **Dependency Batch A** — production-runtime patches for `fast-xml-builder`, `lodash`, `path-to-regexp`, `postcss`.
7. **Dependency Batches BC** — build-tool and codegen patches.
8. **MR-L1 / MR-L2 / MR-L3 / MR-L6 / MR-L7 / MR-L8** — defense-in-depth and hardening.