Skip to content
This repository was archived by the owner on Jul 7, 2026. It is now read-only.

Security: go4cas/cogworks

Security

SECURITY.md

Cogworks Security Model

Trust boundaries

Cogworks has three principal classes:

  1. Anonymous / unauthenticated — public records only (view_rule = null).
  2. User (auth-collection JWT) — gated by per-collection rule expressions.
  3. Admin (cogworks_admin row) — bypasses all collection rules. Admins carry an operator role (F-9): owner | developer | editor | viewer, ascending. Only owner/developer may author hooks, custom routes, scheduled jobs, queue workers, or use the SQL runner — i.e. only they get code execution on the host. editor/viewer exist so you can grant teammates data/read access without that power.

Treat an owner/developer admin as operator-equivalent: authoring a hook is arbitrary code execution in the API process, so compromise of such an account is equivalent to root on the box.

Scope note (F-9, current): roles gate the control plane — authoring code, settings, backups, token minting, admin management. Data-plane per-role enforcement (restricting what editor/viewer may read/write on the records API) is a planned refinement; today every admin still bypasses collection rules for data access, but only owner/developer can author code.

Admin-authored code execution surfaces

Each of these compiles admin-supplied JS via new AsyncFunction("ctx", code) and runs it in the same process as the API server:

  • Hookscogworks/src/core/hooks.ts — fired on record create/update/delete.
  • Custom routescogworks/src/core/routes.ts — mounted under /api/custom/<path>.
  • Cron jobscogworks/src/core/jobs.ts — scheduled execution.
  • Queue workerscogworks/src/core/queues.ts — pulled from the cogworks_jobs_log queue.
  • View collectionscogworks/src/core/collections.ts::createUserView — admin-supplied SQL backs a CREATE VIEW. The validateViewQuery guard rejects DDL/DML keywords and obvious abuse, but a determined admin can still read sensitive tables in their SELECT.

Each compiled function gets a helpers object including helpers.http / helpers.fetch (untrusted-network egress) and helpers.query (passes through the rule engine).

Execution timeout (since E-6): every hook / route / job / worker invocation runs under a Promise.race budget (execution.timeout_ms setting, default 5000; 0 disables) so a hung await (e.g. an unresponsive external service) can't block the request or an event-loop tick indefinitely. Caveat: this only interrupts code that yields to the event loop — a synchronous busy-loop (while (true) {}) still blocks it, since the timer can't fire. Terminating sync loops would require running user code in a Worker; not yet done.

helpers.http SSRF guard (since v0.1.4): every request runs through core/hook-egress.ts::assertEgressAllowed before fetch(). The default deny list blocks RFC1918 private space (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10), loopback (127/8 + ::1), link-local (169.254/16, fe80::/10 — covers AWS / GCP / Azure metadata), and IPv6 unique-local (fc00::/7).

Operator overrides via Settings:

  • hooks.http.deny — comma-separated CIDR list replacing the default (opt-in to a thinner list when the default blocks legitimate internal services). Set to the literal "off" to disable filtering entirely.
  • hooks.http.allow — comma-separated CIDR list evaluated after deny. Punches a hole through (e.g.) 127.0.0.0/8 for a specific sidecar without disabling the rest of the loopback block.

The guard is defense-in-depth — it does not replace kernel-level egress filtering. For hardened deployments still run cogworks under a network namespace / nftables egress filter as belt-and-braces. Known limitation: DNS rebinding (attacker controls the hostname's DNS, flips the answer between our resolve check and fetch()'s internal resolve). Use kernel-level filtering when you need bulletproof guarantees.

helpers.fetch (raw Bun fetch) is NOT routed through the egress guard — it's the unguarded form intentionally exposed for cases where the new SSRF guard would interfere. Legacy hooks that called helpers.fetch keep working; prefer helpers.http.request for new code.

helpers.query does NOT enforce the egress guard — it goes through the SQLite rule engine, not the network.

The egress guard is not a sandbox. It reduces accidental SSRF from helpers.http — it does not contain a hostile hook author. Hook/route/job code runs in the API process with the full JS global scope reachable (fetch, Bun, process, globalThis), so a determined author can bypass the guard entirely: raw fetch, spawn a subprocess, read the data directory, exfiltrate the JWT secret. This is by design — authoring server-side code is a trusted, host-RCE-equivalent capability (see Trust boundaries), which is exactly why it is gated behind the owner/developer roles. Do not treat the egress denylist or the execution timeout as a security boundary against a malicious author; they are guardrails against accidental mistakes in otherwise-trusted code. If you must run untrusted hook code, isolate the whole process at the OS level (network namespace + seccomp/nftables); Cogworks does not sandbox it in-process.

Token lifecycle

JWTs are HS256 signed with COGWORKS_JWT_SECRET (or a generated <dataDir>/.secret fallback at mode 0600). Every token now carries:

  • iss = "cogworks" (verified)
  • aud ∈ {admin, user, file}
  • jti (UUID) — checked against cogworks_token_revocations on every verify
  • iat, exp

Verification path is centralized in core/sec.ts::verifyAuthToken and rechecks:

  • jti not in revocation list
  • principal row still exists (admin/user)
  • password_reset_at <= iat (admin)

POST /api/auth/logout revokes the bearer token's jti. Token rotation uses a sliding-refresh window via POST /api/auth/refresh.

To force-logout every admin (after suspected compromise), update password_reset_at to unixepoch() for that admin row; tokens issued before that timestamp will fail verification on next request.

Storage hardening

Local filesystem mode validates uploaded filenames as ^[uuid]\.[ext]{1,12}$ and resolves every path inside cogworks_data/uploads. Path-traversal attempts are rejected at the storage layer (core/storage.ts::safeLocalPath).

Files served via GET /api/files/:filename always evaluate the parent record's view_rule regardless of whether the field is protected. Image MIME types render inline; everything else gets Content-Disposition: attachment plus X-Content-Type-Options: nosniff to neutralize stored-XSS via uploaded HTML/SVG.

CORS and origin gating

/realtime (WebSocket) and GET /api/realtime (SSE) reject upgrades whose Origin is not in the cors.origins setting (comma-separated; configured in Admin → Settings → CORS). Empty setting = same-origin only; * permits any origin. This is the same allowlist used for HTTP CORS, so cross-origin browsers, WebSocket, and SSE are all governed by one key.

Cross-origin API consumers should set cors.origins (and the rest of the cors.* settings) — Cogworks applies CORS natively, no front proxy plugin required.

Rate-limiting and proxy trust

Per-IP token-bucket rate limiting honors X-Forwarded-For only when the immediate peer is in the COGWORKS_TRUSTED_PROXIES env (CIDR-equivalent, comma-separated). Otherwise the socket peer IP is used. Set this env when running behind Cloudflare, AWS ALB, nginx, etc. — leaving it unset means a hostile client cannot spoof XFF, but any proxy-derived IP also won't be honored.

Cluster mode: rate-limit buckets are per worker process. Under cogworks cluster (N workers), a client spread across workers can reach up to N× a rule's max in aggregate — a deliberate trade-off to keep a DB write off the /api/* hot path. Auth brute-force is unaffected: it is gated by the DB-backed login lockout (shared across workers), not the token-bucket limiter. For strict per-IP API limits across a cluster, enforce them at the reverse proxy in front of the workers.

Setup hardening

POST /api/admin/setup accepts the X-Setup-Key header when COGWORKS_SETUP_KEY is set. Use this on first boot to close the race where an attacker reaches /setup before the operator on a public IP. Print the key from the operator's terminal (e.g., openssl rand -hex 32) and pass it on the request.

The setup endpoint also enforces a 12-character minimum password and atomically refuses if a concurrent setup landed first.

Dependencies of note

  • imagescript and @jsquash/{webp,avif} decode untrusted user image input. Upstream advisories should be monitored. Consider running thumbnail generation in a child process so a decoder OOM does not kill the API.
  • quill 2.0.x and monaco-editor 0.55.x ship custom decoration / hover APIs that have historical XSS risk; audit any custom integrations before exposing rich-text/code editing to non-admin users.
  • @types/bun is pinned to a SemVer range, not latest.

Hardening checklist for production

  • Set COGWORKS_JWT_SECRET (don't rely on .secret fallback).
  • Set COGWORKS_SETUP_KEY on first boot. Unset after creating the seed admin.
  • Set COGWORKS_TRUSTED_PROXIES to your front-door peer IPs.
  • Set cors.origins (Admin → Settings → CORS) for the admin SPA / user app origins.
  • Set COGWORKS_ENCRYPTION_KEY (AES-GCM, 32 bytes base64) if any field type is encrypted.
  • Configure oauth2.<provider>.allowed_redirect_uris for every enabled IdP.
  • Front the binary with TLS termination that sets HSTS; the static landing/docs CF Pages projects already set Strict-Transport-Security.
  • Restrict cogworks_data/ filesystem permissions to the running user (chmod 700).
  • Rotate the JWT secret after any suspected compromise: stop the server, delete .secret (or change the env), restart. All sessions end.

There aren't any published security advisories