Docs Security policy

DocsSecurity and compliance

#Security policy

#Assets and entry points

AssetExposure
HARNESS_SECRET (and HARNESS_SECRET_PREVIOUS)Workers secret; signs confirm/unsubscribe/status tokens (ADR 0006, as amended by ADR 0014: a bounded key ring with revocation as a state — ids in HARNESS_SECRET_REVOKED are refused while configured — and a venture/environment iss binding, so a leaked token or secret does not replay across ventures or environments)
ADMIN_TOKENWorkers secret; gates /v1/<module>/admin/*
RESEND_API_KEY, TURNSTILE_SECRETAdapter secrets, read by the venture's runtime composition
Subscriber/waitlist tables (D1/SQLite)Email addresses + state; per-venture database
Public HTTP surface/v1/email-signup, /v1/waitlist (+ confirm/unsubscribe/status), /__health, /__ready

#Controls (architecture section 11)

  • No enumeration. Signup and waitlist writes answer a byte-identical 202 for every row state; tests compare all four responses byte for byte.
  • Signed, purpose-scoped, expiring tokens. HMAC-SHA256 with key rotation by kid; MAC comparison constant-time (subtle); single-use via row state.
  • Fixed redirects. Confirm/unsubscribe/status redirect only to URLs from Venture config or module builders; a test asserts no route reads redirect/return/next query parameters.
  • Admin auth. cratefield_core::admin::require_admin: disabled (401) when ADMIN_TOKEN is unset, SHA-256-digest constant-time compare, 403 on a wrong token; the token never appears in tracing fields. Set, it is a bearer secret like HARNESS_SECRET and takes the same floor: HarnessConfig::from_config refuses anything under 32 bytes (issue #437). Absent stays legal — admin is simply disabled.
  • Developer API keys (issue #532). cratefield_core::api_key is the store and gate for machine callers a venture enrolls itself: a key is {namespace}_{mode}_{id}_{secret}, the prefix is public and stored in the clear, and the row keeps only SHA-256 over the whole token — with 256 bits of randomness a slow KDF buys nothing, so the argon2id preset of ADR 0200 stays where it belongs (passwords and client secrets, whose inputs may be low-entropy). Verification looks the prefix up, re-derives the digest and compares in constant time, and require_api_key answers one uniform 401 api-key-unauthorized for a missing, malformed, unknown, revoked or wrong-secret credential, 403 api-key-forbidden when a valid key lacks the route's scope, and 503 not-ready when the store itself cannot answer. A RoutePolicy::ApiKey route is guarded — never captcha'd, never rendered into a public surface — and the scope is the handler's per-request argument, since a RoutePolicy is Copy + serde. Rotate commits the revoke and the fresh insert in one atomic batch, and last_used_at is written at most once per minute per key. This is auth, not routing: TENANT-ROUTING.md §3 rejected API keys for host→tenant selection and that rejection stands.
  • Sidecar trust boundary (issue #131, ADR 0009 amendment). A mount forwards an allowlist only: content-type, content-length, accept, accept-language, user-agent, the host's x-request-id, and a cf-connecting-ip the host resolved itself — a client-forged one is replaced, never copied. authorization and cookie never cross, and responses return through an allowlist that drops set-cookie. With SIDECAR_GATEWAY_SECRET configured the host stamps every forwarded request with a purpose-bound, 120 s x-harness-gateway token; a sidecar that sets SIDECAR_REQUIRE_GATEWAY answers 401 sidecar-unauthorized for /v1/* and /__surface it cannot verify, and fails closed (503) when the gate is required without a usable secret. Admin paths under a mount are authorized by the host against its own ADMIN_TOKEN, and only then is the stamp minted under the distinct sidecar-gateway-admin purpose; that purpose, and not the plain stamp every forwarded request carries, is what lets the sidecar's gate re-materialize the sidecar's own admin credential behind it. So no bearer crosses in either direction, and a captured forwarded stamp is not an admin credential. Forwarded writes pass the host's rate limiter and fail closed. A merged sidecar /__surface is byte-capped, contract-checked, limited to the mounted module's public part, and validated.
  • CSV formula-injection guard. cratefield_core::csv::escape prefixes = + - @ \t \r leading cells with ' before RFC 4180 quoting.
  • Rate limits, and a floor that refuses to serve without them (issue #437). The harness layer limits every /admin/* route by client IP, keyed admin:ip:<ip> — its own budget, no email key, since the caller is anonymous until it authenticates. A sidecar mount's admin plane is included: the floor wraps the module and forwarding nests alike, so a denied admin request is never forwarded. It fails closed: a limiter transport error is a bare 429 (no Retry-After — the limiter had no answer), not a pass, because the admin bearer has no captcha or cooldown behind it. The admin login form is throttled the same way (admin-login:ip:<ip>). Readiness then refuses a venture that takes public writes or admin routes unless a rate limiter is actually resolved: a binding named in composition but unresolvable at boot degrades to no limiter, and that now means the guarded routes answer 503 not-production-ready, not a warning over unlimited routes. The rule is also declaration-keyed (issue #562): a module that declares the RateLimiter port — even merely optional(), and even over public reads like a search endpoint, where nothing is writable — holds a production venture to a resolved limiter, and the refusal names the module and its routes (waitlist (POST /v1/waitlist, GET /v1/waitlist/search)) instead of asking the operator to re-derive which route made the gate fire. On Workers, a binding named on the runtime that fails to resolve at request time fails closed in every environment — /v1/* answers 503 not-production-ready naming the binding, once per isolate — because unlimited is a decision the composition makes, never one a missing binding makes for it. The escape is its own recorded decision, HARNESS_ALLOW_UNLIMITED_PUBLIC_ROUTES=<reason>, logged once per boot and deliberately separate from HARNESS_ALLOW_UNPROTECTED_WRITES — one key waiving two controls is how the second stays unwired after the first is fixed. That waiver governs the readiness gate only: it does not lift the Workers missing-binding refusal, which clears when the deployment matches the composition again (or the binding leaves the composition). Where a venture runs a limiter over public routes, keys are ip:<cf-connecting-ip> (never x-forwarded-for on Workers) and email:<normalized>; a denial answers 429 with Retry-After (the limiter's own pause, or the window's reset), plus the IETF draft RateLimit-Limit / -Remaining / -Reset headers when the limiter reports a quota. Which limits apply is a composition choice: the Workers Rate Limiting binding is one limit per namespace, so per-key budgets (per plan, per address) need the D1-backed limiter (D1RateLimiter, issue #538) — the policy maps a key's plan: prefix to its own cap.
  • Production readiness is enforced against the deployment, not a compiled default (issue #143). A venture carries a VentureEnv set in code, defaulting to Development; a deployment carries ENV. They disagreed silently and the weaker answer won, so a Worker shipping ENV = "production" over a venture that never called .env(..) ran with every production-only rule switched off. The stricter of the two now decides, and Harness::router re-checks readiness against the resolved ports: guarded /v1/* routes answer 503 not-production-ready rather than serve unprotected. Probes and the UI stay up so an operator can see why.
  • Captcha mandatory in production when a module declares a [HumanForm] write — or has public_writes() and declares no policy at all, the conservative fallback. Two escapes exist and both are recorded, never silent: fz doctor --allow-no-captcha <reason> for a preview, and HARNESS_ALLOW_UNPROTECTED_WRITES=<reason> on a deployment. The acceptance and the readiness refusal are recorded on every boot on both runtimes (issue #441): natively through the tracing subscriber, on Cloudflare through the control-event forwarder into Workers Logs. A blank reason is not an acceptance.
  • A public write that is not a browser form says so. SignedLink covers a write proved by a single-use, purpose-bound artifact this service issued — a magic link, a passkey or OAuth challenge — and requires a usable Signer rather than a Captcha. Surface-less modules declare it with Module::public_write_policy. This is not an exemption: without a signer there is nothing to issue or verify the artifact with, and production refuses — against the signer the runtime actually resolved, not the one it advertises (issue #478).
  • PII minimalism and retention. See PRIVACY.md.
  • Redaction. Field names matching (?i)secret|token|key| authorization|password are replaced with [redacted]; email values appear only as a 12-hex subject_hash — a keyed HMAC pseudonym (HMAC-SHA256 under a key the runtime derives from HARNESS_SECRET, domain-separated) so a low-entropy address is not dictionary-reversible (issue #135); with no key installed it emits the fixed placeholder 000000000000 instead — fail-closed, never a bare digest. Redaction is also value-level: any other logged string — a generic error, uri or message — is scrubbed by cratefield_core::scrub_text before it reaches a sink, replacing embedded emails with their pseudonym and URL/path queries, dotted signed tokens, Bearer credentials and URL userinfo with [redacted]. DbError, MailError and PushError scrub the wrapped provider or driver message in their own Display, so a Postgres DETAIL: line quoting a row, a Resend 422 quoting the recipient, or a push service quoting an endpoint cannot disclose it (issues #135, #235). The internal-error forwarder scrubs each line it hands to the runtime sink. Rules in cratefield_core::logging, shared by every runtime formatter; verified by tests.
  • Where logs are observable (issues #107, #441). The native runtime installs a redacting JSON subscriber, so every tracing event is observable. Cloudflare installs no dispatcher — installing one hangs the workerd isolate (issue #107) — so a bare tracing::info!/warn!/error! is dropped there. What a Worker does emit are the lines core routes through the control-event forwarder into Workers Logs, each scrubbed by scrub_text and prefixed with its level ([info] , [warn] , [error] ): the boot-time readiness acceptance and refusal, the compiled-versus-deployment env disagreement, sidecar mount-table and gateway misconfiguration, fail-open and fail-closed rate-limiter decisions, the secret-access audit trail, and the internal errors mapped to a 500. The runtime also writes one JSON request-summary line per request — the request span's request_id, method, route (the matched pattern, never the raw path), module, status, ip_hash and ua_family (issue #476). Everything else — ordinary per-request tracing output — is not observable on Workers: a dispatcher still cannot be installed. Module code therefore logs through cratefield_core::module_log! (issue #846), which passes the line through the same scrub_text pipeline and forwards it through the same control-event forwarder into Workers Logs, routed by level to the real console levels; natively it is an ordinary redacted tracing event (see MODULE-AUTHORING.md, Logging from a module).
  • A column is not safer than a log. An error string that is scrubbed on the way to a log and stored raw is the worse half of the pair: it outlives the request, it outlives erasure of the table the value came from, and the log a developer checks says it was redacted. A module that persists error text scrubs it where it binds the column, not at each call site — module-notifications' dead_letter_statement is the reference (issue #235).
  • A failure never names the URL it was sent to. Both HttpClient ports cut a request URL back to its origin before the error goes anywhere — cratefield_core::scrub_request_url, applied over the error's whole source() chain, and then scrub_text on top. The path is not a detail here: an APNs request addresses a device by its path (/3/device/<token>) and a Web Push endpoint is a bearer capability, so a message that quotes the destination publishes the recipient's credential to every log and dead-letter row it reaches. No general rule can recognise that — a path has no secret-looking shape — which is why this pass takes the URL from the caller, the only thing that knows which one it just sent to (issues #228, #229).
  • Token-bearing URLs. Signed links carry their credential in the query (?token=). Every response to a request whose query has a token parameter — on any path, including /ui/* — carries Cache-Control: no-store, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer, so no cache stores the credential and no outbound navigation leaks it through Referer (issue #135). The admin hard-delete route keys on the opaque row id, never the email (DELETE /v1/email-signup/admin/subscribers/{id}).
  • /v1 is never framed. Every /v1/* response additionally carries X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' (issue #435): the API answers in JSON, but the magic-link confirmation is HTML served under /v1, and a confirm page an attacker's page can embed is one it can clickjack. /ui/* sets its own framing headers and stays deliberately outside this rule.
  • No unsafe in core or any module (#![forbid(unsafe_code)]); the only unsafe-adjacent code is worker::send::SendWrapper inside the worker crate (ADR 0002).

#Dependency policy

  • cargo deny check runs in CI on every PR: advisories (vulnerabilities and yanked crates) are deny, licenses restricted to the allowlist in deny.toml, unknown registries and git sources denied.
  • Runtime dependency boundaries are enforced in CI: cratefield-core and every cratefield-module-* must build to wasm32-unknown-unknown and must not pull worker, wasm-bindgen, tokio, reqwest, sqlx or rusqlite (the example venture's worker-build job catches it).
  • Ventures pin exact crate versions; Renovate opens bumps, CI re-runs the full deny + build gate on every bump.

#The native runtime

The native runtime is no longer out of scope (issue #129). What holds today, for the one-venture-per-process shape it actually has:

  • Host. A production deployment answers only for its venture's own domain, api.<domain>, its public URL's host, and any TRUSTED_HOSTS entry; anything else gets 421 Misdirected Request before a handler runs. Loopback names and literals stay reachable so probes and local work keep working. Host is what ADR 0008's per-tenant resolution will key off, so this is the seam that becomes cross-tenant later. x-forwarded-host is deliberately not consulted: a proxy that rewrites the host must rewrite Host itself, rather than the harness growing a second header-trust mechanism beside the one below.
  • Forwarded client IP. Only headers named in TRUSTED_PROXY_HEADERS are believed, in the order given; with the list unset — the default — nothing but the TCP peer is trusted. Every forwarding header is then stripped before the router sees it, so a module's rate-limit key and the request scope's ip_hash read one sanitized verdict.
  • Outbound. Destinations are vetted by scheme, userinfo, name and address, with the IPv4 embedded in mapped, compatible, 6to4 and NAT64 forms judged on its own; every redirect hop is re-vetted and credentials are stripped across an authority change; responses are size- and deadline-bounded and the concurrency budget refuses rather than queues (issue #136).
  • Listening. LISTEN_ADDR defaults to loopback: a deployment open to the world says so explicitly.
  • Production readiness. The deployment's ENV decides, not a compiled default, and a deployment that cannot back its declared abuse controls refuses its guarded routes (issue #143).

Not yet true, and not claimed: per-tenant database handles, secret scoping, blob namespaces, rate-limit keys, adapter caches, or tenant identity persisted with deferred work. Those are the phase-3 work of issues #23-#44 and ADR 0008; there is no tenant to bind them to yet.

#Out of scope

  • The Cloudflare account, D1 infrastructure and Workers secrets provisioning (managed by each venture's deployment workflow).
  • The factory0.ventures website and any front end that posts to the API.
  • Multi-tenant operation of the native runtime. ADR 0008 accepts database-per-tenant with a control database and two-tier secrets, and issues #23-#44 specify it, but crates/runtime-native/ has no tenant identity today: one process serves one venture. The isolation guarantees below are the ones that hold for that shape. When tenant resolution lands, the contract has to be restated against it — see The native runtime above for what already binds.

#Reporting a vulnerability

Report privately to [email protected] (or a GitHub security advisory on Cratefield/harness). Please include reproduction steps and affected commit; do not open a public issue for exploitable findings. We aim to respond within 72 hours and will credit reporters unless anonymity is requested.

Mirrored from docs/SECURITY.md at b6f50cd. The harness is the source of truth: a correction goes there first.