24 — Threat model and security plan
04-isolation-security.md describes the isolation design and the controls that protect one tenant from another and the platform from a tenant. This document widens the view to every trust boundary the product has, lists the threats per boundary with the control that stops them and the test that proves it, and defines the security process (per-phase checklist, PR review, disclosure, incidents). It is the source for the security checklist issue that blocks every phase gate, next to the negative path checklist from 23-failure-modes.md.
Rules:
- A control without a test is not a control. Every row below names its proof; a row without one is
TODOwith the issue that owns it. - Security controls live in Rust or in the control plane, never as a convention tenant code is asked to follow. The SDK may offer helpers, but the platform must be safe when they are not used.
- Fail closed. A missing policy, an unknown host, an unverified domain, an unsigned module, an unparseable header, a missing CSRF token: all of these deny.
- Every security-relevant decision (what was allowed, what was denied, who did it) is logged with
tenant_idandrequest_id, and the logs cannot be edited by the tenant. - No secret value ever leaves the process that needs it: not in logs, errors, bundles, responses, backups in clear, or CI output.
Columns: Threat · Primary control · Secondary control · Proof · Phase.
Trust boundaries
Internet ──(A)──► tenant application ──(B)──► platform (node, control plane)
tenant X ──(C)──► tenant Y module publisher ──(D)──► tenant
Internet ──(E)──► control plane (accounts, API, dashboard, MCP)
upstream deps, CI, releases ──(F)──► us operator / insider ──(G)──► tenant data
AI agents ──(H)──► tenant app and platform
A. End user → tenant application (web layer)
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| XSS through template data | Auto-escape in minijinja; raw only via r-html; javascript: refused in :href | Strict CSP with per-request nonce | template tests; renox check --security | F2 |
| CSRF on actions | Token on every non-GET action, double-submit bound to the session | SameSite=Lax, __Host- cookie | pages tests (#41) | F2 |
| BREACH: CSRF token recovered through compressed responses | Token masked per response (random XOR mask), so the same secret never repeats in a compressed body | No compression for responses that embed tokens when masking is unavailable | crates/renox-cell/src/pages/mod.rs rendered_tokens_are_masked_per_response (#111) | F2 |
| Clickjacking, MIME sniffing, referrer leaks | Default header set (frame-ancestors 'none', nosniff, Referrer-Policy) | renox check --security prints effective headers per route | gateway tests (#47) | F2 |
Open redirect via next/return_to | redirect.safe() SDK helper: same-origin or allowlisted only; auth module uses it everywhere | renox check lint on Response.redirect(url.searchParams…) | #111 (sdk/tests/redirect.test.ts, cli/tests/redirect-check.test.ts); auth: modules/auth/tests/auth.test.ts "auth redirects go through the redirect.safe() rules" (#275) | F2/F4 |
| Login brute force, credential stuffing, enumeration | Stricter buckets on login/register/reset; identical responses for unknown and wrong password; argon2id | 2fa module; lockout with notification | auth negative tests (#100); argon2id cost: crates/renox-runtime/tests/password.rs argon2id_hash_time_at_the_platform_parameters and bench/results/2026-10-29-argon2id-dev-container.md (dev container; node-small with O3), length rule: modules/auth/tests/negative/password-hashing.test.ts "password length" (#275); behind the Cloudflare Tunnel the login bucket keys on CF-Connecting-IP only from a trusted peer, a forged header from any other peer keeps its socket address: control/api/tests/trusted-proxy.test.ts "login: …" (#366) | F4 |
Session fixation, token replay, reset-token reuse, OAuth state/PKCE | Session rotated on login; single-use tokens; state and PKCE verified | Session absolute and idle expiry | #100; reset link from another user-agent family: modules/auth/tests/negative/token-replay.test.ts "reset link: from another browser family refused" (#275) | F4 |
| Page, action or handler reached without an authorization check | Route guard evaluated in Rust with the request's principal before the loader, the action, the handler and fragment requests; deny is 403 RENOX_FORBIDDEN and the loader never runs | ctx.authz.require in handlers; renox check refuses a page under pages/ with a policy requirement and no guard (#283) | #279 (crates/renox-cell/tests/authz.rs guard_denies_before_the_loader_and_on_fragments, crates/renox-runtime/tests/authz_ops.rs guard_denies_before_the_handler); missing-guard: #283 (cli/tests/check-authz.test.ts "renox check: missing-guard and unscoped-owned-table on a project, and r-can actions") | F5 |
| A policy broader than intended (a role or user allowed what it should not be) | Policy test cases per role in policies/tests/*.json, run by renox test through the engine the cell builds (a wrong expectation fails the run) | Policies validated against the strict merged schema at renox check, renox build and cell load (V24); renox authz explain | #283 (cli/tests/test-policies.test.ts "policy cases run in renox test; a wrong expectation fails the run", "the auth and org modules' policy cases pass against their policies"; #292: examples/shop/policies/tests/shop.json (viewer, finance under and over the threshold, finance_manager, finance without 2FA, another organisation) and examples/shop/tests/authz.test.ts "without the second factor: no refund button, and the curl POST is denied"; cli/tests/check-authz.test.ts "renox check: an invalid policy fails with its file and line") | F5 |
| Cross-site WebSocket hijacking (CSWSH) | Origin must match the app's host or a CORS origin on upgrade; a cookie-bearing WebSocket upgrade without Origin is refused; 403 before the hub or isolate; auth at connect from the session cookie | Topics as Cedar resources | #118: crates/renox-realtime/tests/security.rs origin_same_host_and_cors_origins_pass_any_other_site_is_refused, origin_absent_a_websocket_with_a_cookie_is_refused; crates/renox-cell/tests/realtime.rs a_cross_origin_upgrade_with_a_cookie_is_refused; cli/tests/dev-upgrade.test.ts (docs/contracts/ops.md §18) | F5 |
| Subscribing to another user's topic | Topics as Cedar resources, fail closed when no policy permits | Topic names derived from the principal, never from input alone | #118: crates/renox-realtime/tests/security.rs a_subscribe_the_policy_did_not_allow_is_refused, crates/renox-cell/tests/realtime.rs a_denied_subscribe_in_open_closes_with_1008, crates/renox-authz/tests/fail_closed.rs (docs/contracts/ops.md §18, "Authorization: topics as Cedar resources"); #289 (modules/notifications/tests/notifications.test.ts "two SSE clients: notify reaches alice's badge and list, never bob's; another user's topic is refused") | F5 |
| Mass assignment / unvalidated input | validate(req, Schema) before handler bodies | Lint warns on await req.json() without a schema (unvalidated-body, a warning) | SDK tests (#44); cli/tests/lint.test.ts "unvalidated-body: …" (#275) | F2 |
| Timing attacks on signature or token comparison | Constant-time compare in webhook.verify, CSRF check, token check | Helper exported by the SDK | #111 | F2 |
| Payment widget needs third-party scripts | Module manifest declares CSP sources; tenant approves at install; gateway merges | allow_unsafe flagged in the dashboard | #47 | F2 |
B. Tenant code → platform
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| CPU, memory, wall-clock exhaustion | Watchdog, heap limit, deadline, semaphore (all in Rust) | cgroup per process | #26, B12 | F1 |
| SSRF to metadata, loopback, private ranges | Resolve DNS, check every IP, then connect to the checked IP (no second resolution); every redirect hop re-checked | Egress proxy with the same rules | #27; DNS pinning #108 (crates/renox-runtime/tests/fetch_rebinding.rs) | F1 |
| Tenant code reaches the control plane's node-facing endpoints (route tables, secret bundles, artifacts, certificates, ingestion), control-plane isolation | Two listeners (ADR D23): the public one 404s /v1/nodes/* and /v1/ingest/* before any handler runs, the node one serves only those (control/api/src/app.ts, main.ts); a controlled worker refuses to start when [control] url resolves to an address tenant fetch could reach (not private, not in fetch_blocked_addresses) | renox_node_ token on every node endpoint; the public API address is neither blocked nor exempt, so internal tenants get no extra reach | #218 (control/api/tests/node-surface.test.ts, crates/renox-cell/tests/controlled.rs control_url_reachable_by_tenant_fetch_refuses_to_start) | F3 |
DNS rebinding on fetch (TOCTOU between check and connect) | Connect to the already-validated socket address; happy-eyeballs disabled for tenant fetch | TTL ignored for policy purposes | #108 | F1 |
TLS downgrade or invalid certificate on fetch | rustls with webpki roots; no insecure option for tenants; HTTP only when the host is explicitly declared with http:// | — | #108 | F1 |
| Secrets in logs, errors, bundle, responses | Secrets injected as a frozen object by an op; redaction on every log and error path | Bundle hygiene (docs/contracts/deploy-api.md 0.1 section 4): renox build scans the sources before bundling and refuses on a finding; at upload the control plane refuses forbidden files anywhere (H1) and secrets in files stored as they are, public/, migrations/, templates/ (H2). code/ is scanned by the CLI only (bundling strips the allow markers, vendored SDKs give false positives, and code/ is never served); a hand-built code/ with a secret is an accepted residual risk | #28; #115 (control/api/tests/bundle.test.ts, packages/artifact-validate/tests/hygiene.test.ts); the Resend key of the control plane's mailer in no error, log line or JSON of the mailer, and no refusal of the config repeats it: control/api/tests/mailer.test.ts (#385) | F1/F3 |
| Audit library append-only; tenant cannot edit or delete audit rows | audit__events triggers RAISE(ABORT) on every UPDATE and on a DELETE younger than 30 days, for every connection (module code, app code, a tool opening the file); module triggers may only select raise(...) (migration runner); renox__authz_audit refused UPDATE/DELETE/DROP/ALTER by the authorizer for everyone | Other modules cannot write audit__* at all (authorizer); reading the trail is the sensitive action audit.read, itself audited | #288 (modules/audit/tests/audit.test.ts "append-only: module code and app code cannot update or delete; neither can a tool opening the file"; crates/renox-data/src/migrate.rs guard_triggers_pass_and_anything_else_is_refused); #277 (crates/renox-data/tests/authz_audit.rs) | F5 |
| Cross-module table access | SQLite authorizer per module prefix at prepare time | db.scoped for module code | #19 | F1 |
| Code persisted without validation | Immutable, hash-addressed artifacts; validation in the control plane; no filesystem or npm at runtime | Worker verifies the hash before loading | #30, #52 | F1/F3 |
| Isolate escape (V8 bug) | Sandboxed process: the worker applies its own seccomp allowlist (traced, crates/renox-cell/sandbox/syscalls.<arch>.txt, kill on a miss) and Landlock rules (its own dirs only; TCP connect and bind by port with ABI 4) at start, [sandbox] required in controlled mode (#253); systemd units with NoNewPrivileges, ProtectSystem=strict and per-tier cgroup slices (ops/systemd, #252) | Tier separation; suspicious → microVM; V8 updates within days of a Deno release | #253 (crates/renox-cell/tests/sandbox.rs: /etc/passwd and 169.254.169.254 refused with the profile applied, a database tenant served under enforce; the suite under RENOX_TEST_SANDBOX=enforce); #254 (crates/renox-cell/tests/supervisor.rs: a paid tenant in a child process of its own, which refuses other tenants with 403, with its own cgroup limits and its data dir only); #75; pentest | F6 |
| Side channels between isolates | SharedArrayBuffer off, coarse timers | Paid tier: one process per tenant | runtime config test | F1/F6 |
| Realtime fan-out amplification | Per-connection and per-tenant limits, backpressure | Fan-out outside the isolate | B14 (docs/contracts/ops.md §18, "Limits" and "Metering"); end to end crates/renox-cell/tests/realtime.rs the_tenant_cap_refuses_the_upgrade, a_publish_from_an_action_reaches_two_connections_without_the_isolate (#281); one tenant's flood does not delay another's: crates/renox-realtime/tests/security.rs a_flood_on_one_tenant_does_not_delay_another_tenant (#118) | F5 |
| Scheduled job runs as a privileged principal | Jobs run as System::"app" with explicit permissions only | Charged to the tenant quota | jobs tests | F6 |
C. Tenant → tenant
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| Reading another tenant's data | File per tenant; tenant id from routing, never from input | Authorizer; process isolation per tier | cross-tenant test | F1 |
Host/SNI confusion: TLS for tenant A, Host for tenant B | Routing uses the Host header after TLS; SNI and Host must name the same tenant or the request is refused | Certificates per hostname only | crates/renox-gateway/tests/protocol.rs sni_for_one_tenant_and_host_for_another_is_421, h2_authority_for_another_tenant_is_421 (#107) | F2 |
HTTP request smuggling, Transfer-Encoding tricks, h2c upgrade | hyper strict parsing; TE with Content-Length refused; no cleartext upgrade; HTTP/1.1 pipelining limits | Gateway is the only process on the listener | #107 | F2 |
| HTTP/2 rapid reset and stream floods | Max concurrent streams, reset rate limit, per-connection budget | Per-IP connection limit | #107 | F2 |
| Cache poisoning of static assets | Cache key = tenant + hostname + path; unkeyed headers never influence the response | Immutable hashed asset paths | #107 | F2 |
Path traversal or dotfiles under public/ | Static files served only from the artifact's public/manifest.json, never from a filesystem path built from the request | CLI excludes dotfiles and symlinks at build | #22; #115 | F1/F3 |
| Custom domain takeover (dangling CNAME, re-claimed hostname) | Ownership record persists after removal; another tenant cannot claim a hostname while it is owned; periodic re-verification | Deleted *.renoxium.app slugs quarantined before reuse | control/api/tests/domains-ownership.test.ts "two tenants racing for one hostname: …", "detached: another tenant is refused for 30 days, …", "TXT removed: serving stops within one interval, …", "a deleted app's name is quarantined for 90 days …" (#114) | F3 |
Shared-domain reputation (one phishing tenant gets *.renoxium.app blocked) | renoxium.app registered on the Public Suffix List; cookies and Safe Browsing scoped per subdomain; the platform's own services and mail on a separate domain, renoxium.com, so a blocked tenant domain never takes the dashboard, the API or our mail with it (#363) | Abuse takedown SLA | #114; #123 | F3/F7 |
Cookie tossing between tenants: a tenant Set-Cookie with Domain=renoxium.app (or a parent, or another tenant's host) planted on every other tenant's host | Gateway removes any Domain= that is not the request host, so the cookie is host-only; malformed Set-Cookie dropped; log names the cookie, never the value | renoxium.app on the Public Suffix List (ops/psl/README.md); platform cookies are __Host- | crates/renox-gateway/tests/cookies.rs (#207) | F2 |
| Noisy neighbour | Per-tenant quotas enforced in Rust | Scheduler avoids busy workers | B12 | F1 |
D. Module publisher → tenant
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| Malicious or compromised marketplace module | Ed25519 signature verification; permission review at install; diff on upgrade | isolate mode; global disable by hash; tenant notification | control/api/tests/registry.test.ts and control/dashboard/tests/modules.test.ts (signature, permission review, upgrade diff, disable by hash with the owner's mail and banner; #324); isolate mode: crates/renox-cell/tests/isolate_mode.rs (no app globals, only approved secrets, own table prefix and hosts, the 1 MiB cap, its own heap limit) (#320) | F7 |
| Module calling a service or hook it did not declare | Broker checks the manifest on every call | Authorizer for tables | broker tests (#63) | F4 |
| Module migration touching another module's tables | Authorizer attached to migration connections too | Migration runner per module prefix | migration tests (#117) | F4 |
| Module reading secrets it did not declare | Secrets filtered per module at injection | Tenant approves the list at install | secrets tests | F4 |
| Template override reads module data the loader did not return | An override renders with the module's loader result only; a name the loader did not return renders empty | Overrides are the tenant's own code, reviewed with the drift report (renox check, V20) | crates/renox-cell/tests/modules_pages.rs an_override_cannot_read_data_the_loader_did_not_return (#275) | F4 |
E. Internet → control plane (accounts, API, dashboard, MCP)
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| Account takeover of a tenant developer | Passkeys/TOTP for control-plane accounts (control/api/src/auth/mfa.ts; the tenant-side 2fa module for apps); email verification; login buckets; sessions with absolute and idle expiry, revocable per device | New-device and new-token notifications; a notice on every second-factor change | #113; #293 (control/api/tests/mfa.test.ts "enrol, then a password sign-in is held until the second factor; a replayed code is refused", "a recovery code works once", "secrets, tokens and the team need a verification within 15 minutes", "an owner without a factor is held at enrolment after MFA_GRACE_DAYS…", "needs the password and a current code…", "register and verify with the recorded ES256 vectors…"; the IDOR matrix test/idor/idor.test.ts covers the MFA routes); the dashboard security page with the user's own token #307 (control/api/tests/mfa.test.ts "a user's token reaches the MFA endpoints with its own step-up window; a bootstrap or impersonation token is 401", control/dashboard/tests/security.test.ts); agent tokens #286 (modules/auth/tests/agents.test.ts); verification and reset mail actually delivered (Resend), and a failed delivery never logs the message whose link holds the single-use token: control/api/tests/mailer.test.ts "in the API" (#385) | F3/F5 |
| Leaked CLI or CI token | Tokens scoped (app, action), expiring, hashed at rest, renox_pat_ prefix registered with GitHub secret scanning, last-used tracking, one-click revoke | Deploys from a new token are audit-logged and notified | #113 | F3 |
| Teammate with too much power | Roles per tenant: owner, admin, developer, viewer; explicit permission matrix (deploy, secrets, domains, billing, team) | Secrets write and team changes require admin; audit log | #113 | F3 |
| Tenant-scoping bug in the control-plane API (IDOR) | Every query carries the tenant id from the authenticated principal; repository layer refuses queries without it | Integration test per endpoint with a second tenant's ids | #113 | F3 |
| SQL injection, SSRF, deserialisation in the Bun API | Parameterised queries only (lint); no outbound fetch from user input except the domain verifier with fixed targets | Dependency scanning | #109; #116; control/api/tests/sql-lint.test.ts (string-built SQL refused outside the reviewed control/api/sql-lint-allow.json, #275) | F3 |
| Deploy API abuse (huge uploads, bombs) | Size caps, decompression limits, rate limits per token | Validation shares code with renox check | #95, #52; per-token buckets (20/s, burst 100; 10 uploads/min; proposed, deploy-api.md section 2): control/api/tests/rate-limits.test.ts (#275) | F3 |
| Remote MCP tool misuse by an agent | OAuth 2.1 scopes; confirmation token for mutating tools; audit | Dry-run mode | #62 | F3 |
| Operator console abuse | Separate identity provider, hardware MFA, break-glass with reason and logging; operator actions need ops:write, which only an account flagged operator can mint (a tenant token, a session or * never holds it), and carry a reason of 10 to 500 characters (deploy-api.md section 17) | Operator actions audited (operator, tenant, reason, result) and reviewed | control/api/tests/ops-tenants.test.ts "a non-operator cannot obtain ops:write…" (scope) and the audit assertions of the tier and move tests (#259); crates/renox-cell/tests/move_hook.rs (the node's move needs the callback token and a reason); MFA and break-glass review → #121; break-glass and ops:read-only tokens: control/api/tests/breakglass.test.ts (#310: "an ops:read token reaches getOpsTenant and nothing that writes", "an operator action's audit row names the operator as acct_, never usr_") | F6 |
F. Supply chain → us
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| Vulnerable or malicious crate / npm package | cargo deny (advisories, sources, licences) and bun audit in CI; Dependabot or Renovate with grouped weekly PRs | Lockfiles committed; --locked / --frozen-lockfile in CI | #109: CI jobs cargo-deny and bun-audit (.github/workflows/ci.yml) | F1 |
| Tampered artifact in object storage | The worker hashes every downloaded artifact (canonical hash) before caching it; a mismatch is refused and nothing is cached | The cache is re-verified on first use (LocalArtifactStore) | crates/renox-cell/tests/artifact_s3.rs a_tampered_object_is_refused_with_hash_mismatch (#275) | F3 |
Tampered prebuilt rusty_v8 binary | Pinned version; sha256 of the archive verified against a checksum committed to the repo (RUSTY_V8_ARCHIVE from a mirror we control) | Build fails on mismatch | #109 | F1 |
| Compromised GitHub Action | Every action pinned to a commit SHA; minimal permissions: per job; no secrets in PR builds from forks | Branch protection, required reviews, signed commits encouraged | #109 | F1 |
Tampered release of renox-worker or the renox CLI (runs on every tenant laptop via renox dev) | Releases signed with sigstore/cosign with SLSA provenance; the CLI verifies the worker's signature and hash before executing it | Checksums published; reproducible build goal | #129 | F3 |
| Secret leaked through CI logs | Secrets masked; no set -x with secrets; tokens short-lived (OIDC to cloud) | — | #109 | F1 |
| Tenant dependencies frozen at deploy but vulnerable | SBOM per artifact; advisory scan at deploy with a dashboard warning | Future: block on critical | #123 (warning only); getAdvisories (deploy-api.md 0.5 section 11.2): scanned at upload (control/api/tests/advisories.test.ts), re-scanned daily for active deployments keeping the last answer when OSV is down (control/api/tests/abuse-controls.test.ts "the daily OSV re-scan") | F7 |
G. Operator / insider → tenant data
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
| Tenant files or backups read from storage | Node disks encrypted at rest (cloud disk encryption or LUKS); Litestream and Postgres backups encrypted with SSE-KMS using a key separate from the secrets master key | Buckets private, access logged, no public ACLs possible; an export is readable only through a signed URL that expires within 15 minutes (deploy-api.md section 14) | control/api/tests/backups.test.ts "export answers a signed URL; the object is not readable without the signature" (bare path 404, forged or expired signature 403; #258); encryption at rest: node disks are the provider's encrypted volumes or LUKS (an owner step of ops/terraform, #309), Litestream replicas and exports use SSE-KMS; exports are written with SSE-KMS and the export key (EXPORT_KMS_KEY_ID; #308: control/api/tests/backups.test.ts "with EXPORT_KMS_KEY_ID the export is put with SSE-KMS and that key", "the S3 store signs the SSE-KMS headers into the put") and the bucket refuses any other put (#309) | F6 |
| Key compromise | Key hierarchy documented: TLS keys, secrets master key, backup key, token signing key; rotation runbook per key with a tested procedure. Inventory, custody, rotation steps and the drill record: ops/security/keys.md; the KMS keys (rotation on) and the export bucket as code: ops/terraform/ (#309) | HSM/KMS for the master key | #120; #309 (bench/ops/bucket-policy.test.ts "an unencrypted put is refused, an SSE-KMS put succeeds" against MinIO, tofu validate in .github/workflows/ops.yml); the drills are owner steps | F6 |
| Erasure incomplete (PDPL right to erasure) | Tenant delete removes the file, replicas, exports; backups expire within a documented window; the deletion is audited with the completion time. Operation: deleteTenant (DELETE /v1/tenants/{tenant_id}, owner session only, confirm = the tenant id) with the pending → erasing → done | failed state machine and getTenant showing it (docs/contracts/deploy-api.md 0.4 section 3) | Export before delete offered | #120; operation in deploy API 0.4 (#303); #308: control/api/tests/erasure.test.ts "the owner erases the tenant: 202 pending, then done with the routes, tokens, node file, exports and records gone; audited; emailed", "a node that is down keeps the erasure in erasing; past the 24 h time box it is failed at node_drop with an alert…", "a second DELETE answers the same record; a token, a non-owner and a wrong confirm are refused…" | F6 |
| Operator reads tenant data | Break-glass only: reason, time-boxed, logged, reviewed. Operation: impersonateTenant / endImpersonation (POST/DELETE /v1/ops/tenants/{tenant_id}/impersonate, scope ops:impersonate that no default operator token holds, reason 10–500 characters, read scopes only, 60-minute time box, an audit row at start and end, every request with the token audited as the operator's; docs/contracts/deploy-api.md 0.4 section 17) | Tenant owner emailed at start and end; active impersonations in getOpsTenant.actions[] | #121; operation in deploy API 0.4 (#303); #310: control/api/tests/breakglass.test.ts "only an ops:impersonate token starts one…", "start: a read-only tenant token for 60 minutes, audited with the reason, the owner emailed; its requests are the operator's in the audit…", "end: by the operator now, or by the time box…; another tenant's id is 404"; the console and the banner: control/dashboard/tests/breakglass.test.ts | F6 |
| Tenant export file mishandled | Exports encrypted at rest, expiring signed URL, audit row | — | #120 | F6 |
H. AI agents → tenant application and platform
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
Prompt injection through tenant content reaching ctx.ai or app MCP tools | Tool outputs treated as data; mutating tools require confirmation tokens; per-tool permissions in the manifest | Audit of every tool call | 20-ai-agents-mcp.md; #62; ctx.ai local tools run under the caller's ctx and limits, the conversation is never logged: crates/renox-runtime/tests/ai.rs (#322) | F3/F7 |
| Agent deploys malicious code with a leaked token | Same as E (scoped, expiring tokens; confirmation for mutations) | Notification on deploy from a new client | #113 | F3 |
| AI gateway secret or quota abuse | Provider keys held by the platform, never by the isolate; metered per tenant | Allowlisted providers | crates/renox-runtime/tests/ai.rs (#322: gateway mode never calls a provider from the isolate, the key of direct mode never in a log line, RENOX_AI_QUOTA); aiChat, the quota, cache and key rules (deploy-api.md 0.5 section 23), #327 | F7 |
Abuse of the platform (not a boundary, but a threat)
| Threat | Primary control | Secondary control | Proof | Phase |
|---|---|---|---|---|
Phishing or malware sites on *.renoxium.app | Public Suffix List entry; abuse report endpoint and abuse@ contact; takedown SLA with suspension; Safe Browsing lookups on deployed hostnames | Free tier requires a verified email and has no custom domains until a payment method exists | #123; reportAbuse, setAbuseTicket and the abuse states, Safe Browsing on getOpsTenant (deploy-api.md 0.5 section 22); report → ticket → takedown → suspended entries with the tenant's name, audited, another tenant on the node unchanged: control/api/tests/abuse.test.ts; the neutral page: crates/renox-gateway/tests/suspended.rs. Safe Browsing on activation, daily and on a report, an unsafe phishing hostname taken down at once with the owner emailed and the other tenant untouched, the 4-hour takedown SLA alert, and the reporter's 90 days: control/api/tests/abuse-controls.test.ts ("Safe Browsing", "the takedown SLA"); production needs RENOX_SAFE_BROWSING_KEY (owner step) | F7 |
| Card testing through tenant checkouts | Billing module: rate limit on payment attempts per IP and session, 3DS by default, CAPTCHA signal hook | Provider-side fraud tools | The hook point billing.fraud_signal runs before every checkout session and a halt refuses it before the PSP is called (modules/billing/tests/billing.test.ts "billing.fraud_signal…", #329); the limits that fill it are #123 | F7 |
Outbound spam through the email module | Per-tenant send rate and daily cap; SPF/DKIM per sending domain; suppression list; complaint feedback loop pauses the tenant | Shared sending domain only for transactional templates | #123 | F7 |
| Bot signups | Email verification, disposable-domain blocklist, signup rate per IP, delayed quota growth for new tenants | Manual review for free tier anomalies | #123; signup friction and new-tenant restrictions (deploy-api.md 0.5 section 21); a 50-request bot script from one address creates 3 tenants (3 per IP per hour): control/api/tests/signup.test.ts "a bot sign-up script…"; reduced quotas: "reduced_quotas…"; 20 per ASN per day, the CAPTCHA checked with the provider before anything is stored (failing closed), no custom domain while new or flagged: control/api/tests/abuse-controls.test.ts ("sign-ups per ASN", "CAPTCHA", "no custom domain…"); production needs RENOX_CAPTCHA_SECRET and an RENOX_ASN_DB table (owner step); behind the tunnel the per-IP sign-up cap counts the client address a trusted proxy names, never a forged header: control/api/tests/trusted-proxy.test.ts "sign-up: …" (#366) | F7 |
| Crypto mining, proxying | CPU and egress quotas; anomaly detection on meters; suspend | Move to microVM | control/api/tests/billing.test.ts (the tier pass to suspended with the owner's emails; the anomaly rule writes one review row and the signal), control/billing/tests/tier.test.ts (#325); the neutral page of a suspended host, no cell touched: crates/renox-gateway/tests/suspended.rs, control/api/tests/routes.test.ts "suspending turns the tenant's routes into suspended entries…" | F7 |
| A paid plan used without paying, or a stolen card on a Renox plan | The card is saved on the tenant's own Stripe Customer through a setup intent (no card data reaches Renox) and each invoice is charged off-session; a decline is retried 1, 3 and 7 days after the invoice date with an email to the owner; an invoice unpaid 14 days suspends the tenant; a dispute suspends it at once | Stripe's own fraud screening on the platform account | control/api/tests/billing-collect.test.ts (#350: a Customer per tenant, the close charges and pays, the retries and emails, suspension at 14 days and reinstatement by a new card, the PaymentIntent webhooks from payloads recorded on a sandbox); control/api/scripts/stripe-smoke.ts against a Stripe sandbox (not in CI); a dispute suspends at once, found through its PaymentIntent because Stripe's dispute carries no metadata of ours: control/api/tests/billing.test.ts "webhooks…" and "a dispute whose PaymentIntent is on no invoice row…" with a payload recorded on the sandbox (#351) | F7 |
Security process
- Security checklist per phase. Each phase has a
security checklistissue listing the rows above due in that phase; the gate review is blocked by it: F1 #110, F2 #112, F3 #116, F4 #117, F5 #119, F6 #122, F7 #124. - Security section in every PR.
.github/PULL_REQUEST_TEMPLATE.mdasks: new input from the network or from tenant code? new place a secret flows through? new table, op, header, or route? If yes, name the row in this document and the test. - Early adversarial review. The SSRF filter (#27, #108), the authorizer (#19), and the gateway parser (#107, F2) get a recorded adversarial review session when they merge, not at the F6 penetration test. The session's findings are issues.
- Penetration test before F6 closes (#79), scope defined in #121; findings closed or accepted with a written reason.
- Disclosure and incidents.
/.well-known/security.txt, a disclosure policy, and an incident runbook (contain, rotate, notify within the PDPL window, post-mortem) exist before external customers (#121);security.txtis served for the platform hosts by the gateway (#121) and onapi.renoxium.combycontrol/api/src/well-known.ts(#310:control/api/tests/breakglass.test.ts"security.txt on the API host"). - Dependency cadence. Dependabot/Renovate PRs reviewed weekly; a critical advisory in a runtime dependency is a same-week release (#109).
- V8 updates. A new Deno/Supabase Edge Runtime release that carries a V8 security fix is picked up within the week; this is a standing task, not a phase item.