Principle: treat every AI worker as an untrusted contractor, and every input — including model output, fetched web pages and anything a client sends — as hostile. No control relies on a system prompt. Every rule is enforced server-side, inside the module that owns the data.
This document describes what is implemented, with file pointers, plus the runbooks and the risks we are knowingly carrying. Audit finding ids (F-…, INF-…, OPS-…) refer to the production-readiness audits for this phase.
Assets. Tenant business data (jobs, specs, deliverables, run traces, chat), stored tool credentials, model spend (real money on platform keys), and platform integrity/availability.
Trust boundaries.
| Boundary | Crossing | Treated as |
|---|---|---|
| Browser → app | Server actions, route handlers, Auth.js | Hostile input; session identity is re-read from the database on every request |
| App → model provider | src/server/models/** only |
Untrusted output; nothing it returns is an instruction |
| Model → tools | tools.invoke() only |
Every call re-authorized against the worker version's grants |
| Tool → internet | src/server/tools/net-guard.ts, guarded-http.ts |
SSRF-hostile; addresses re-checked at connect time |
| Web page → model context | fetch_url / web_search output |
Data, never instructions (indirect prompt injection) |
Adversaries.
- Unauthenticated attacker: credential stuffing, session forgery, IDOR probing, DoS on expensive endpoints.
- Authenticated tenant (malicious or compromised): cross-tenant reads, cost abuse, privilege escalation inside the workspace.
- Hostile web content: a page that tells the worker to exfiltrate what it has collected, or to poison a deliverable.
- The worker itself (excessive agency): runaway loops, unbounded spend, unintended external side effects.
- Operator error / supply chain: leaked keys, vulnerable dependencies, a misconfigured deployment.
- Auth.js v5 credentials provider; bcrypt (cost
config.auth.bcryptRounds), identical failure message for unknown-user and wrong-password, and a timing-equalizer hash so a missing account costs the same as a wrong password —src/server/auth/authorize.ts. - JWT sessions: 12 h sliding, 7 d absolute,
sessionVersionin the token.loadSessionContextre-reads the user on every request and rejects a token whosesessionVersionis behind, or whose user is disabled —src/server/auth/session-context.ts. A password change, "sign out everywhere", a role change or a removal bumps it, which revokes every existing cookie. - Sign-in throttling and lockouts (F-002): 5 failures / 15 min per
sha256(email)and 30 / 15 min per client IP, then an exponential lockout (window × 2^n, capped at 24 h). Both are checked before the password compare, and the account rule is applied whether or not the account exists, so lockouts are not an account oracle —src/server/security/rate-limit.ts+src/server/auth/authorize.ts. - Self-serve sign-up is gated by
SIGNUP_MODE(open/invite/closed, closed by default in production). Invitations are single-use, expiring, and stored assha256(token)—src/server/account/**. - The demo workspace is refused at sign-in unless
DEMO_MODE=true(F-001).
- Every query and mutation is scoped by
organizationIdtaken from the session, never from the request. A child resource is verified throughwhere: { id, organizationId }; a mismatch isNOT_FOUND, neverFORBIDDEN— no existence oracle. Ids arecuid(). - Roles (MEMBER / ADMIN / OWNER) are enforced inside the server modules with
assertCan(session, permission)—src/server/auth/permissions.ts. The UI only hides what you may not do; the server refuses it. Deciding an approval for anexternal_writetool re-reads the deciding user's role from the database. - Route handlers answer
401/404JSON instead of redirecting, and never leak whether an id exists in another workspace —src/app/api/runs/[runId]/route.ts,src/app/(app)/deliverables/[deliverableId]/download/route.ts.
- One Postgres-backed fixed-window limiter (
RateLimitBucket), one atomicINSERT … ON CONFLICT DO UPDATEper hit, so counts are correct across instances and concurrent requests —src/server/security/rate-limit.ts. Rules: sign-in (account/IP), sign-up, invite accept/create, password change, LLM per user and per org, runs per org, credentials per org, and the run-page poll endpoint (429 +Retry-After). - Failure policy: auth rules fail closed, everything else fails open and logs. The whole limiter is a no-op when
RATE_LIMIT_DISABLED=1(refused in production byenv.ts). - Monthly real spend per workspace is tracked in
OrgSpendMonth(UTC month, atomic increment) and enforced againstOrganization.monthlyBudgetUsd ?? PLATFORM_DEFAULT_MONTHLY_BUDGET_USD—src/server/security/budget.ts. Simulated runs cost nothing and are never blocked. assertOrgActiveis the operator kill switch (Organization.suspendedAt): no runs, no LLM calls.- Both checks are repeated as a backstop immediately before every LIVE model call, so a code path that forgets them still cannot spend —
src/server/models/index.ts. - Per-run ceilings (cost, tool calls, duration) are clamped to platform maxima by
clampRunLimits, and per-worker/per-org in-flight run caps live in the runtime queue.
- Single choke point.
tools.invoke()is the only execution path: registry lookup → Zod validation →authorize(tool in the blueprint and a non-revoked grant for this worker version) → approval check bytoolCallId→ execute → usage record. The runtime and the UI never call a tool'sexecute()—src/server/tools/invoke.ts. - Approvals.
send_notification(sideEffect: "external_write") defaults to requiring approval; the run pauses and a human sees the exact payload. Only anAPPROVEDrow for thatToolCallin that workspace lets it run;PENDING,REJECTED,EXPIREDand "no row" all refuse. Regression test:tests/security/tool-provenance.test.ts. - SSRF. Scheme and port allow-lists, DNS resolution with private/reserved/metadata ranges blocked, redirects capped and re-validated per hop, and the connection re-checked at connect time so DNS rebinding cannot reach a blocked address —
src/server/tools/net-guard.ts,guarded-http.ts. - Exfiltration via
fetch_url(F-010). In live mode a URL is capped at 2,048 characters, its fragment is stripped, and the host must have provenance in this run: it appeared in this run's ownweb_searchresults, or literally in the job brief / spec / this run's instructions. Anything else is refused with aTOOL_ERRORtelling the model to search first —src/server/tools/provenance.ts. Simulated mode is unaffected. - No dynamic execution. No
eval, noFunction(), no shelling out;calculatoris a hand-written expression parser. - Bounded context. Tool outputs are size-capped and schema-validated before re-entering the model context; trace payloads are clipped field-by-field.
- Markdown is parsed into an AST and rendered as React elements — no HTML string is ever produced, no
dangerouslySetInnerHTML, and link targets are restricted tohttp(s),mailto:and same-site paths —src/lib/markdown.ts,src/components/markdown.tsx. - The parser is hardened against pathological input (INF-17): no quadratic regexes, bounded look-ahead and recursion, a 2,000-character per-line block-scan limit and a 200 KB document cap. Regression tests assert a time budget on adversarial input —
tests/security/markdown.test.ts. - CSV output escapes formula prefixes (
= + - @, tab, CR). - Deliverable downloads are served inert:
nosniff,Content-Security-Policy: sandbox; default-src 'none',Cache-Control: private, no-store, and a sanitized ASCIIfilenameplus RFC 5987filename*—src/app/(app)/deliverables/[deliverableId]/download/route.ts.
- Static headers on every response (HSTS in production,
nosniff,X-Frame-Options: DENY, referrer, permissions, COOP, CORP, noX-Powered-By) —next.config.ts. - A per-request nonce-based Content-Security-Policy with
strict-dynamic,object-src 'none',base-uri 'self',form-action 'self',frame-ancestors 'none'andupgrade-insecure-requestsin production —src/server/security/csp.ts+src/middleware.ts. Redirect and 401 responses get an inert deny-all policy.style-srckeeps'unsafe-inline'because React SSR emits style attributes and the toast library injects a stylesheet; there is no raw-HTML rendering path, so that residual is small. - A DNS-rebinding guard for the self-hosted stack on plain
http://localhost: whenAUTH_URLishttp://, the middleware answers only requests whoseHostis a loopback name or theAUTH_URLhost and returns421for anything else, so a website the owner visits cannot rebind its domain to 127.0.0.1 and sign up or run workers on the owner's key —src/server/security/local-host.ts+src/middleware.ts.https://deployments skip it.npm run devalso binds tolocalhostrather than every interface. - Server actions: Next.js origin checking (do not add a wildcard
SERVER_ACTIONS_ALLOWED_ORIGINS) plus a 256 KB body limit. Every export of a"use server"file is a public endpoint, sotests/security/server-actions.test.tsasserts that each one is an async function that starts fromrequireSession()(with a named allow-list for sign-in, sign-up, invite acceptance and sign-out), and that route handlers exportGETonly (F-017).
- Tool credentials are encrypted with AES-256-GCM under
CREDENTIAL_ENCRYPTION_KEY. The envelope isv2:<kid>:<iv>:<tag>:<ciphertext>, wherekididentifies the key and the GCM AAD binds the ciphertext toorganizationId:name— moving a row to another workspace or renaming it makes it undecryptable (F-014) —src/server/secrets/crypto.ts. - Values are never logged, never returned by any list endpoint, and
last4is only kept for values of 12+ characters. - A credential that cannot be decrypted does not fall back to the platform env key (that would silently spend the platform's own key): the tool drops to Simulated mode and the operator sees
secrets.unreadable_credential—src/server/secrets/vault.ts. setCredential/deleteCredentialrequirecredentials.manage(ADMIN), are rate limited per workspace, and writeCREDENTIAL_SET/CREDENTIAL_DELETEDaudit events carrying the credential name only.- Model-provider keys are process-wide environment variables, never per-tenant in Phase 1.
env.tsrefuses to boot in production with a missing/weakAUTH_SECRET, a badCREDENTIAL_ENCRYPTION_KEY, a non-HTTPSAUTH_URLor a database URL without TLS.
SecurityEventrecords sign-ins, throttles, lockouts, sign-outs, password and role changes, invitations, credential changes, rate-limit blocks and budget stops. It storessha256(email)— never the address — the client IP, a 256-character user agent, and metadata with secret-looking keys and values scrubbed —src/server/security/audit.ts.recordSecurityEventnever throws.- The client IP is read from the right of
X-Forwarded-ForusingTRUSTED_PROXY_HOPS, never the leftmost (client-controlled) value —src/server/security/request.ts. - Logs are structured JSON lines with clipped,
redactSecrets-scrubbed fields; error objects are never logged whole (Prisma metadata can contain row values) —src/server/security/log.ts,redact.ts. - Tenant-facing errors never carry internals:
runActionpasses through only the AppError codes whose message is written for users, and turns everything else into one generic sentence plus a(ref xxxxxx)that appears in the log line —src/lib/action-result.ts,src/server/security/public-error.ts. Provider text (request ids, echoed prompts, keys) stays inModelCall.errorandAppError.details, scrubbed.
RETENTION_TRACE_DAYS (default 30) clears ModelCall request/response payloads; RETENTION_EVENTS_DAYS (default 365) deletes activity and security events; rate-limit buckets idle for two days are swept (sweepRateLimits). The maintenance tick runs them — src/server/maintenance/**.
- Generate a key:
openssl rand -base64 32. - Deploy with
CREDENTIAL_ENCRYPTION_KEY=<new>andCREDENTIAL_ENCRYPTION_KEY_PREVIOUS=<old>. Both old (v1/v2) and new rows decrypt; new writes use the new key. - Re-encrypt every stored credential:
reencryptAll()from@/server/secrets(idempotent, batched; rows already on the active key are skipped). It reports{ scanned, reencrypted, failed }. - Confirm
failed === 0, then removeCREDENTIAL_ENCRYPTION_KEY_PREVIOUSand redeploy. Anyfailedrow must be re-entered by a workspace admin in Settings. - Keep an offline escrow copy of the key, separate from database backups — losing it means losing every stored credential.
Deploy with the new secret; Auth.js accepts an array, so keep the previous value in AUTH_SECRET_PREVIOUS for one release to avoid signing everyone out, then drop it. To force a global sign-out instead, increment every User.sessionVersion.
UPDATE "Organization" SET "suspendedAt" = now() WHERE id = '…'; — runs stop being claimed, LLM calls refuse, the UI explains why. To cap instead of stop, set monthlyBudgetUsd.
- Contain. Suspend the affected workspace (3.3). For a suspected credential compromise, delete the credential and rotate the provider key at the provider.
- Revoke sessions. Increment
sessionVersionfor the affected users (all sessions for that user stop on their next request). - Assess.
listSecurityEvents(organizationId)for the actor's trail (sign-ins, IPs, credential and role changes);ModelCall/ToolCall/ActivityEventfor what the worker did;UsageRecordandOrgSpendMonthfor spend. - Find the error. A user-reported
(ref xxxxxx)appears verbatim in the JSON log line that carries the real cause. - Rotate.
CREDENTIAL_ENCRYPTION_KEY(3.1),AUTH_SECRET(3.2), provider keys, and the database password if the host was reachable. - Record. Timeline, blast radius (which workspaces, which data), fix, and the regression test that keeps it fixed.
- Indirect prompt injection is mitigated, not solved. Provenance limits where data can go, approvals gate sending, and tool output is bounded — but a hostile page can still steer a worker's reasoning and poison a deliverable's content. Human review of deliverables remains the real control.
- No per-tenant provider keys. All live model spend runs on platform keys, bounded by the monthly budget and the run limits. A tenant that exhausts its budget stops; the platform still carries the cost until billing exists.
- No self-service password reset. There is no email provider in this phase. An owner-initiated reset (or a support-assisted one) is the only path; invitations are shared as links by the inviting admin.
- No 2FA / SSO. Passwords only. A stolen password gives full workspace access until the session version is bumped.
style-src 'unsafe-inline'. Required by React SSR style attributes and the toast library; scripts are nonce-only.- Rate limits are per-subject, not per-network. A distributed attacker with many IPs can still spread sign-in attempts across accounts; the per-account rule is the backstop.
- Trace payloads contain tenant data (prompts, fetched page text) until the retention sweep clears them. Access to the database is access to that data.
simulatedmode is the default everywhere. It is safe by construction, but it also means the live paths get less production mileage; treat the first live deployment as a fresh surface.- Deliverable content is not scanned for malware; it is worker-generated text served inert and never executed.
For Foreman itself, open a private security advisory at https://github.com/karank2512/Foreman/security/advisories/new. If you run your own deployment, route reports to whoever operates it. Include: what you found, how to reproduce it, what you could access, and whether any tenant data was involved. Please do not open a public issue, do not test against workspaces that are not yours, and do not run denial-of-service tests against shared infrastructure. We aim to acknowledge within two business days and to ship a fix or a mitigation before any public disclosure; we are happy to credit you.
npx vitest run tests/security # limiter windows/lockouts/concurrency, CSP, redaction, budget, provenance, routes
npx vitest run tests/auth tests/tools tests/platform/secrets.test.ts
npm run audit:prod # dependency advisories (expected: 0)
curl -sI https://<host>/ | grep -Ei 'content-security-policy|strict-transport|x-frame|x-content-type'After a deploy, load one page per route group with the browser console open and confirm there are no CSP violations (charts, dialogs and toasts are the ones worth checking), and confirm /api/health and /api/ready are reachable without a session while every other route redirects or 401s.