Renoxium Docs Pricing Sign in Sign up

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:

  1. A control without a test is not a control. Every row below names its proof; a row without one is TODO with the issue that owns it.
  2. 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.
  3. 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.
  4. Every security-relevant decision (what was allowed, what was denied, who did it) is logged with tenant_id and request_id, and the logs cannot be edited by the tenant.
  5. 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)

ThreatPrimary controlSecondary controlProofPhase
XSS through template dataAuto-escape in minijinja; raw only via r-html; javascript: refused in :hrefStrict CSP with per-request noncetemplate tests; renox check --securityF2
CSRF on actionsToken on every non-GET action, double-submit bound to the sessionSameSite=Lax, __Host- cookiepages tests (#41)F2
BREACH: CSRF token recovered through compressed responsesToken masked per response (random XOR mask), so the same secret never repeats in a compressed bodyNo compression for responses that embed tokens when masking is unavailablecrates/renox-cell/src/pages/mod.rs rendered_tokens_are_masked_per_response (#111)F2
Clickjacking, MIME sniffing, referrer leaksDefault header set (frame-ancestors 'none', nosniff, Referrer-Policy)renox check --security prints effective headers per routegateway tests (#47)F2
Open redirect via next/return_toredirect.safe() SDK helper: same-origin or allowlisted only; auth module uses it everywhererenox 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, enumerationStricter buckets on login/register/reset; identical responses for unknown and wrong password; argon2id2fa module; lockout with notificationauth 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/PKCESession rotated on login; single-use tokens; state and PKCE verifiedSession 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 checkRoute 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 runsctx.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 cookieTopics 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 topicTopics as Cedar resources, fail closed when no policy permitsTopic 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 inputvalidate(req, Schema) before handler bodiesLint 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 comparisonConstant-time compare in webhook.verify, CSRF check, token checkHelper exported by the SDK#111F2
Payment widget needs third-party scriptsModule manifest declares CSP sources; tenant approves at install; gateway mergesallow_unsafe flagged in the dashboard#47F2

B. Tenant code → platform

ThreatPrimary controlSecondary controlProofPhase
CPU, memory, wall-clock exhaustionWatchdog, heap limit, deadline, semaphore (all in Rust)cgroup per process#26, B12F1
SSRF to metadata, loopback, private rangesResolve DNS, check every IP, then connect to the checked IP (no second resolution); every redirect hop re-checkedEgress 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 isolationTwo 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 fetchTTL ignored for policy purposes#108F1
TLS downgrade or invalid certificate on fetchrustls with webpki roots; no insecure option for tenants; HTTP only when the host is explicitly declared with http://—#108F1
Secrets in logs, errors, bundle, responsesSecrets injected as a frozen object by an op; redaction on every log and error pathBundle 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 rowsaudit__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 everyoneOther 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 accessSQLite authorizer per module prefix at prepare timedb.scoped for module code#19F1
Code persisted without validationImmutable, hash-addressed artifacts; validation in the control plane; no filesystem or npm at runtimeWorker verifies the hash before loading#30, #52F1/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; pentestF6
Side channels between isolatesSharedArrayBuffer off, coarse timersPaid tier: one process per tenantruntime config testF1/F6
Realtime fan-out amplificationPer-connection and per-tenant limits, backpressureFan-out outside the isolateB14 (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 principalJobs run as System::"app" with explicit permissions onlyCharged to the tenant quotajobs testsF6

C. Tenant → tenant

ThreatPrimary controlSecondary controlProofPhase
Reading another tenant's dataFile per tenant; tenant id from routing, never from inputAuthorizer; process isolation per tiercross-tenant testF1
Host/SNI confusion: TLS for tenant A, Host for tenant BRouting uses the Host header after TLS; SNI and Host must name the same tenant or the request is refusedCertificates per hostname onlycrates/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 upgradehyper strict parsing; TE with Content-Length refused; no cleartext upgrade; HTTP/1.1 pipelining limitsGateway is the only process on the listener#107F2
HTTP/2 rapid reset and stream floodsMax concurrent streams, reset rate limit, per-connection budgetPer-IP connection limit#107F2
Cache poisoning of static assetsCache key = tenant + hostname + path; unkeyed headers never influence the responseImmutable hashed asset paths#107F2
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 requestCLI excludes dotfiles and symlinks at build#22; #115F1/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-verificationDeleted *.renoxium.app slugs quarantined before reusecontrol/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; #123F3/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 hostGateway 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 valuerenoxium.app on the Public Suffix List (ops/psl/README.md); platform cookies are __Host-crates/renox-gateway/tests/cookies.rs (#207)F2
Noisy neighbourPer-tenant quotas enforced in RustScheduler avoids busy workersB12F1

D. Module publisher → tenant

ThreatPrimary controlSecondary controlProofPhase
Malicious or compromised marketplace moduleEd25519 signature verification; permission review at install; diff on upgradeisolate mode; global disable by hash; tenant notificationcontrol/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 declareBroker checks the manifest on every callAuthorizer for tablesbroker tests (#63)F4
Module migration touching another module's tablesAuthorizer attached to migration connections tooMigration runner per module prefixmigration tests (#117)F4
Module reading secrets it did not declareSecrets filtered per module at injectionTenant approves the list at installsecrets testsF4
Template override reads module data the loader did not returnAn override renders with the module's loader result only; a name the loader did not return renders emptyOverrides 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)

ThreatPrimary controlSecondary controlProofPhase
Account takeover of a tenant developerPasskeys/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 deviceNew-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 tokenTokens scoped (app, action), expiring, hashed at rest, renox_pat_ prefix registered with GitHub secret scanning, last-used tracking, one-click revokeDeploys from a new token are audit-logged and notified#113F3
Teammate with too much powerRoles per tenant: owner, admin, developer, viewer; explicit permission matrix (deploy, secrets, domains, billing, team)Secrets write and team changes require admin; audit log#113F3
Tenant-scoping bug in the control-plane API (IDOR)Every query carries the tenant id from the authenticated principal; repository layer refuses queries without itIntegration test per endpoint with a second tenant's ids#113F3
SQL injection, SSRF, deserialisation in the Bun APIParameterised queries only (lint); no outbound fetch from user input except the domain verifier with fixed targetsDependency 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 tokenValidation 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 agentOAuth 2.1 scopes; confirmation token for mutating tools; auditDry-run mode#62F3
Operator console abuseSeparate 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 reviewedcontrol/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

ThreatPrimary controlSecondary controlProofPhase
Vulnerable or malicious crate / npm packagecargo deny (advisories, sources, licences) and bun audit in CI; Dependabot or Renovate with grouped weekly PRsLockfiles committed; --locked / --frozen-lockfile in CI#109: CI jobs cargo-deny and bun-audit (.github/workflows/ci.yml)F1
Tampered artifact in object storageThe worker hashes every downloaded artifact (canonical hash) before caching it; a mismatch is refused and nothing is cachedThe 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 binaryPinned 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#109F1
Compromised GitHub ActionEvery action pinned to a commit SHA; minimal permissions: per job; no secrets in PR builds from forksBranch protection, required reviews, signed commits encouraged#109F1
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 itChecksums published; reproducible build goal#129F3
Secret leaked through CI logsSecrets masked; no set -x with secrets; tokens short-lived (OIDC to cloud)—#109F1
Tenant dependencies frozen at deploy but vulnerableSBOM per artifact; advisory scan at deploy with a dashboard warningFuture: 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

ThreatPrimary controlSecondary controlProofPhase
Tenant files or backups read from storageNode 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 keyBuckets 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 compromiseKey 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 stepsF6
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 dataBreak-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.tsF6
Tenant export file mishandledExports encrypted at rest, expiring signed URL, audit row—#120F6

H. AI agents → tenant application and platform

ThreatPrimary controlSecondary controlProofPhase
Prompt injection through tenant content reaching ctx.ai or app MCP toolsTool outputs treated as data; mutating tools require confirmation tokens; per-tool permissions in the manifestAudit of every tool call20-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 tokenSame as E (scoped, expiring tokens; confirmation for mutations)Notification on deploy from a new client#113F3
AI gateway secret or quota abuseProvider keys held by the platform, never by the isolate; metered per tenantAllowlisted providerscrates/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), #327F7

Abuse of the platform (not a boundary, but a threat)

ThreatPrimary controlSecondary controlProofPhase
Phishing or malware sites on *.renoxium.appPublic Suffix List entry; abuse report endpoint and abuse@ contact; takedown SLA with suspension; Safe Browsing lookups on deployed hostnamesFree 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 checkoutsBilling module: rate limit on payment attempts per IP and session, 3DS by default, CAPTCHA signal hookProvider-side fraud toolsThe 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 #123F7
Outbound spam through the email modulePer-tenant send rate and daily cap; SPF/DKIM per sending domain; suppression list; complaint feedback loop pauses the tenantShared sending domain only for transactional templates#123F7
Bot signupsEmail verification, disposable-domain blocklist, signup rate per IP, delayed quota growth for new tenantsManual 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, proxyingCPU and egress quotas; anomaly detection on meters; suspendMove to microVMcontrol/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 planThe 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 onceStripe's own fraud screening on the platform accountcontrol/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

  1. Security checklist per phase. Each phase has a security checklist issue 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.
  2. Security section in every PR. .github/PULL_REQUEST_TEMPLATE.md asks: 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.
  3. 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.
  4. Penetration test before F6 closes (#79), scope defined in #121; findings closed or accepted with a written reason.
  5. 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.txt is served for the platform hosts by the gateway (#121) and on api.renoxium.com by control/api/src/well-known.ts (#310: control/api/tests/breakglass.test.ts "security.txt on the API host").
  6. Dependency cadence. Dependabot/Renovate PRs reviewed weekly; a critical advisory in a runtime dependency is a same-week release (#109).
  7. 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.

Source on GitHub