DocsSecurity and compliance
#Security policy
#Assets and entry points
| Asset | Exposure |
|---|---|
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_TOKEN | Workers secret; gates /v1/<module>/admin/* |
RESEND_API_KEY, TURNSTILE_SECRET | Adapter 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
202for 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
Ventureconfig or module builders; a test asserts no route readsredirect/return/nextquery parameters. - Admin auth.
cratefield_core::admin::require_admin: disabled (401) whenADMIN_TOKENis 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 likeHARNESS_SECRETand takes the same floor:HarnessConfig::from_configrefuses anything under 32 bytes (issue #437). Absent stays legal — admin is simply disabled. - Developer API keys (issue #532).
cratefield_core::api_keyis 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 onlySHA-256over 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, andrequire_api_keyanswers one uniform401 api-key-unauthorizedfor a missing, malformed, unknown, revoked or wrong-secret credential,403 api-key-forbiddenwhen a valid key lacks the route's scope, and503 not-readywhen the store itself cannot answer. ARoutePolicy::ApiKeyroute is guarded — never captcha'd, never rendered into a public surface — and the scope is the handler's per-request argument, since aRoutePolicyisCopy+ serde. Rotate commits the revoke and the fresh insert in one atomic batch, andlast_used_atis 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'sx-request-id, and acf-connecting-ipthe host resolved itself — a client-forged one is replaced, never copied.authorizationandcookienever cross, and responses return through an allowlist that dropsset-cookie. WithSIDECAR_GATEWAY_SECRETconfigured the host stamps every forwarded request with a purpose-bound, 120 sx-harness-gatewaytoken; a sidecar that setsSIDECAR_REQUIRE_GATEWAYanswers401 sidecar-unauthorizedfor/v1/*and/__surfaceit 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 ownADMIN_TOKEN, and only then is the stamp minted under the distinctsidecar-gateway-adminpurpose; 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/__surfaceis byte-capped, contract-checked, limited to the mounted module's public part, and validated. - CSV formula-injection guard.
cratefield_core::csv::escapeprefixes= + - @ \t \rleading 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, keyedadmin: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 bare429(noRetry-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 answer503 not-production-ready, not a warning over unlimited routes. The rule is also declaration-keyed (issue #562): a module that declares theRateLimiterport — even merelyoptional(), 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/*answers503 not-production-readynaming 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 fromHARNESS_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 areip:<cf-connecting-ip>(neverx-forwarded-foron Workers) andemail:<normalized>; a denial answers429withRetry-After(the limiter's own pause, or the window's reset), plus the IETF draftRateLimit-Limit/-Remaining/-Resetheaders 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'splan:prefix to its own cap. - Production readiness is enforced against the deployment, not a compiled default (issue #143). A venture carries a
VentureEnvset in code, defaulting toDevelopment; a deployment carriesENV. They disagreed silently and the weaker answer won, so a Worker shippingENV = "production"over a venture that never called.env(..)ran with every production-only rule switched off. The stricter of the two now decides, andHarness::routerre-checks readiness against the resolved ports: guarded/v1/*routes answer503 not-production-readyrather 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 haspublic_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, andHARNESS_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.
SignedLinkcovers a write proved by a single-use, purpose-bound artifact this service issued — a magic link, a passkey or OAuth challenge — and requires a usableSignerrather than aCaptcha. Surface-less modules declare it withModule::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|passwordare replaced with[redacted]; email values appear only as a 12-hexsubject_hash— a keyed HMAC pseudonym (HMAC-SHA256under a key the runtime derives fromHARNESS_SECRET, domain-separated) so a low-entropy address is not dictionary-reversible (issue #135); with no key installed it emits the fixed placeholder000000000000instead — fail-closed, never a bare digest. Redaction is also value-level: any other logged string — a genericerror,urior message — is scrubbed bycratefield_core::scrub_textbefore it reaches a sink, replacing embedded emails with their pseudonym and URL/path queries, dotted signed tokens,Bearercredentials and URL userinfo with[redacted].DbError,MailErrorandPushErrorscrub the wrapped provider or driver message in their ownDisplay, so a PostgresDETAIL:line quoting a row, a Resend422quoting 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 incratefield_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
tracingevent is observable. Cloudflare installs no dispatcher — installing one hangs the workerd isolate (issue #107) — so a baretracing::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 byscrub_textand 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'srequest_id,method,route(the matched pattern, never the raw path),module,status,ip_hashandua_family(issue #476). Everything else — ordinary per-requesttracingoutput — is not observable on Workers: a dispatcher still cannot be installed. Module code therefore logs throughcratefield_core::module_log!(issue #846), which passes the line through the samescrub_textpipeline 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 redactedtracingevent (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_statementis the reference (issue #235). - A failure never names the URL it was sent to. Both
HttpClientports cut a request URL back to its origin before the error goes anywhere —cratefield_core::scrub_request_url, applied over the error's wholesource()chain, and thenscrub_texton 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 atokenparameter — on any path, including/ui/*— carriesCache-Control: no-store,X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer, so no cache stores the credential and no outbound navigation leaks it throughReferer(issue #135). The admin hard-delete route keys on the opaque row id, never the email (DELETE /v1/email-signup/admin/subscribers/{id}). /v1is never framed. Every/v1/*response additionally carriesX-Frame-Options: DENYandContent-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
unsafein core or any module (#![forbid(unsafe_code)]); the onlyunsafe-adjacent code isworker::send::SendWrapperinside theworkercrate (ADR 0002).
#Dependency policy
cargo deny checkruns in CI on every PR: advisories (vulnerabilities and yanked crates) are deny, licenses restricted to the allowlist indeny.toml, unknown registries and git sources denied.- Runtime dependency boundaries are enforced in CI:
cratefield-coreand everycratefield-module-*must build towasm32-unknown-unknownand must not pullworker,wasm-bindgen,tokio,reqwest,sqlxorrusqlite(the example venture'sworker-buildjob 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 anyTRUSTED_HOSTSentry; anything else gets421 Misdirected Requestbefore a handler runs. Loopback names and literals stay reachable so probes and local work keep working.Hostis what ADR 0008's per-tenant resolution will key off, so this is the seam that becomes cross-tenant later.x-forwarded-hostis deliberately not consulted: a proxy that rewrites the host must rewriteHostitself, rather than the harness growing a second header-trust mechanism beside the one below. - Forwarded client IP. Only headers named in
TRUSTED_PROXY_HEADERSare 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'sip_hashread 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_ADDRdefaults to loopback: a deployment open to the world says so explicitly. - Production readiness. The deployment's
ENVdecides, 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.ventureswebsite 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.