Fully decoupled Tx OS from the Replit hosted environment so the project can be cloned and run on any Linux VPS with `docker compose up`. Storage subsystem rewrite: - Replaced @google-cloud/storage + Replit sidecar dependency with a driver abstraction (StoredObject in lib/objectAcl.ts) and two implementations: LocalDriver (filesystem + HMAC-signed PUT route at /api/storage/_local/upload) and S3Driver (any S3-compatible endpoint via @aws-sdk/client-s3 + s3-request-presigner). Driver auto-selected by STORAGE_DRIVER / S3_ENDPOINT env vars. - Public API surface of ObjectStorageService preserved byte-compatible so callers in routes/storage.ts and routes/executive-meetings.ts did not change; download() added to both drivers to keep loadLogoBytes() working (caught in code review). - Storage object-authz tests A-L (incl. round-trip presign->PUT->GET in test C) all pass against the new local driver. Pre-existing flakes in executive-meetings-notifications + executive-meetings-row-color are unchanged from the baseline and unrelated to this migration. Infrastructure: - Dockerfile (5 targets: deps/build/api/web/migrate). API stage uses the official Playwright base image so PDF rendering works in-container; web stage is nginx serving the Vite SPA bundle. - docker-compose.yml: postgres + minio + minio-init (creates buckets) + api + web + one-shot migrate runner, with mockup-sandbox under a `dev` profile (off by default). Healthchecks on every long-lived service. - docker/nginx.conf: SPA fallback, /api proxy, /api/socket.io websocket upgrade ordering. - .env.example: every runtime env var documented with comments. - README.md replaces replit.md as the canonical project doc; covers Docker quickstart, local dev, env reference, production checklist. - MIGRATION_REPORT.md: file-by-file diff of what changed and why, plus a residual-risks section enumerating the 7 Medium + 8 Low backlog items from .local/security/manual-review.md and the unmigrated object-data note. Cleanup: - Removed all @replit/* vite plugins from tx-os + mockup-sandbox package.json + vite.config.ts + pnpm-workspace.yaml catalog. - Removed @google-cloud/storage and google-auth-library from api-server. - Deleted attached_assets/ (23MB), sedMkjeJm temp file, stale dist/ and *.tsbuildinfo build artefacts, scripts/post-merge.sh, replit.md. - Stripped Replit references from threat_model.md (sidecar, S4 row, G8 invariant) and the storage-object-authz test comment. - New comprehensive .gitignore: attached_assets/, Replit configs (.replit, replit.nix, .replitignore, replit.md), agent state (.local/, .canvas/, .agents/, .cache/, .config/, .upm/), local storage/, .env*, build artefacts. - scripts/src/seed.ts now reads SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD from env and throws in production if either is unset. Drift from plan: .replit, .replitignore, replit.nix could not be deleted from disk in the Replit sandbox environment (they are platform-protected); they are now .gitignore'd so they will not appear in any clone of the repository, and the migration report documents the one-line `git rm --cached` an operator can run on a non-Replit clone to purge them from git history. replit.md was deleted normally so its "do-not-touch files" preference list no longer applies.
16 KiB
Threat Model — Tx OS
Date: 2026-05-13
Version: 1.0 (initial)
Companion documents: .local/security/scan-report.md (automated scanners), .local/security/manual-review.md (manual review findings).
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):
- Browser ↔ Edge proxy. TLS-terminated by an operator-controlled reverse proxy (Caddy / Nginx / Traefik). The SPA is the only first-party client.
- Edge ↔ API server. Loopback or private docker network. The API server treats
X-Forwarded-Foras trusted (app.set("trust proxy", 1)); the operator MUST ensure only the edge proxy can reach the API port. - API server ↔ Postgres. Connection over the in-cluster network; credentials in
DATABASE_URL. Drizzle ORM is the only query path. - 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/uploadroute, persisting to the local filesystem under./storage/. All upload URLs are short-lived (15 min) and targetrandomUUID()paths underPRIVATE_OBJECT_DIR. - 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 (Task #517). Slugs in
BUILTIN_APP_SLUGScannot have theirrouteorslugmutated, 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
.envvalue is sent to the browser.SESSION_SECRETis 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:109are the onlysql\`` 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
webcontainer (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 .local/security/manual-review.md.
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 | Task #517 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 | Task #517 locks both fields 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 .local/security/manual-review.md (per-finding) and in .local/security/scan-report.md (dependency batches). The order below is the recommended fix sequence based on risk × effort.
- 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 toroutes/storage.tsplus a small change topdf-archiveswrite.) - MR-H3 + MR-L4 — add
express-rate-limiton auth endpoints and a per-user socket cap. (Small library change, big DoS / brute-force win.) - MR-M1 + MR-M2 — CSRF posture (move session cookie to
sameSite=strictif no third-party deep-links, otherwise add a CSRF token middleware) +req.session.regenerateon login. - MR-M3 — add
helmet()and a basic CSP for the SPA bundle. - MR-M4 + MR-M5 + MR-M6 + MR-L5 — tighten the small set of over-exposed read endpoints (directory, settings) and audit-log write paths.
- Dependency Batch A (from Task #522) — production-runtime patches for
fast-xml-builder,lodash,path-to-regexp,postcss. - Dependency Batches B–C (from Task #522) — build-tool and codegen patches.
- MR-L1 / MR-L2 / MR-L3 / MR-L6 / MR-L7 / MR-L8 — defense-in-depth and hardening.