Threat model: public APIs
The externally reachable surface of KilnCMS, its trust boundaries, the controls in place, and the residual risks an operator should watch (issue #51). It is a living document — revisit it whenever a new public route, socket, or outbound integration is added.
Authorization internals (who-may-do-what per resource) live in
policy-matrix.md and granular-rbac.md;
data retention and PII handling live in data-flows.md. This
document is about the network edge.
Assets
- Published content — public by design; integrity (no unauthorized edits) and availability matter, confidentiality does not.
- Draft / in-review content — must never leak before publish.
- Audience-restricted content — published but readable only by users holding a matching audience (see the policy matrix). Confidentiality matters, and since #337 Phase 2 the audience may be paid for, so a leak is also revenue loss. Audiences resolve per-organization and fail closed for a foreign org.
- User accounts & roles — credentials, password hashes, TOTP secrets,
passkey credentials, and the
role/audiencesattributes driving RBAC. - Auth tokens — AshAuthentication JWTs, magic-link and password-reset tokens, preview tokens, collab and bridge socket tokens.
- API keys —
kiln_…bearer keys carrying a:reador:read_writescope. - Tenant isolation — one deployment serves multiple organizations; content, media, branding and analytics must not cross org boundaries.
- Media & object storage — uploaded files and their storage credentials.
- Outbound webhook secrets — HMAC signing keys for delivery, encrypted at
rest with
KilnCMS.Keys.Vault. - Payment credentials — the provider API key and the inbound-webhook signing
secret, both held through the
KilnCMS.Keysprovider model. The API key has full authority over the payment account; the signing secret is what stops forged entitlement grants. Off by default — an unconfigured instance exposes no payment surface at all. - Membership state — provider customer/subscription ids, pseudonymous but a live external reference: acting on them from a clone would affect real billing, which is why the staging scrub severs them.
- Form submissions — arbitrary end-user input, frequently PII.
Trust boundaries & entry points
Requests pass through the endpoint plug stack (lib/kiln_cms_web/endpoint.ex)
before routing. Three controls live there rather than in the router, and so
apply to every surface below: Plugs.ClientIp (proxy-aware remote_ip),
Plugs.SetTenant (host → organization), and Plugs.ApiCORS (mounted ahead of
the router so preflights are answered before route matching).
| Surface | Route(s) | Auth | Rate bucket |
|---|---|---|---|
| Public HTML delivery | /, /:slug, /:type/:slug, /blog, /blog/:slug, /search, /*path | none | :delivery |
| Probes & SEO | /up, /ready, /sitemap.xml, /news-sitemap.xml, /robots.txt, /.well-known/security.txt (+ /security.txt redirect), /llms.txt, /manifest.webmanifest, /offline.html | none | :probe |
| First-run bootstrap | /setup | none — the :bootstrap_admin policy (Checks.NoAdminExists) + advisory lock in KilnCMS.Accounts.Bootstrap are the gate | none |
| GraphQL | /gql (GET + POST), /ws/gql | optional JWT / API key | :gql (per operation, on both transports), :gql_join (socket connects) |
| JSON:API | /api/json/** (GET/POST/PATCH/DELETE) | optional JWT / API key | :api |
| API landing | GET /api (302 to /developers) | none | none (:browser pipeline) |
| Headless REST | /api/content/**, /api/resolve, /api/locales, /api/search, /api/ask, /api/provenance/**, /api/visual-editing/:type/:slug, /api/sync, /api/schema, /api/menus/**, /api/content/:type/:id/revisions/** | optional JWT / API key | :api |
| OpenAPI & explorer | /api/json/open_api, /api/json/swaggerui | none — and not served in prod unless API_DOCS_ENABLED (#567); the document (never the explorer) also answers any valid API key | :docs |
| GraphQL SDL | GET /api/graphql/schema.graphql | none where introspection is on; API key required in prod unless GRAPHQL_INTROSPECTION_ENABLED | :docs |
| Headless sign-in | POST /api/auth/sign_in | credentials → JWT, or a pending token for a 2FA account | :auth + per-account (#478) |
| Headless second factor | POST /api/auth/sign_in/verify | encrypted pending token + TOTP or recovery code | :auth; the same per-account second-factor budget as the browser prompt (#714, #726) |
| Content unlock | POST /api/content/:type/:slug/unlock, POST /_unlock (CSRF) | passphrase | :unlock (POST /_unlock also :delivery) |
| Media upload | POST /api/media, /api/media/import-url, /api/media/uploads[/complete] | JWT / API key; :read_write + editor, checked before POST /api/media's body is parsed (the endpoint leaves it unread) | :api + :media_upload |
| MCP (LLM authoring) | /mcp | API key required | :api |
| Public forms | GET /api/forms/:slug, POST /forms/:slug, POST /api/forms/:slug | none (no CSRF by design) | :form |
| Form embed | GET /forms/:slug/embed | none | :delivery (framing CSP) |
| Preview | /preview/:token, /preview/:token/live, /preview/release/:token | signed token is the credential | :preview |
| Headless draft mode | GET /api/json/*, GET /api/content/:type/:slug and GET /api/visual-editing/:type/:slug with x-kiln-preview-token / ?preview_token= (#1887) | signed token, pinned to the serving site, admits its one record on the primary read only; never read on a write | :api + :preview_api |
| Newsletter | POST /newsletter/subscribe, /newsletter/confirm/:token, /newsletter/unsubscribe/:token | honeypot / opaque token; the confirm and unsubscribe GETs only render a page, the POST changes the subscription (#1664) — confirm's POST is also CSRF-checked | :form |
| Billing webhook | POST /billing/webhooks/stripe | HMAC over raw body | :billing_webhook |
| ActivityPub | GET /.well-known/webfinger, /actor, /actor/outbox, /actor/followers, /ap/object/:id; POST /actor/inbox | none (GET) / HTTP Signature over the raw body (inbox) | :probe (GET) / :api (inbox) |
| Calendars / feeds | /calendar.ics, /:plural/calendar.ics, /:plural/:slug/calendar.ics, /:plural/index.json, /feed.xml, /feed.json, /:plural/feed.{xml,json} (and the category/tag variants) | none (published only) | :probe |
| Membership (paid audiences) | /membership | optional session | :delivery |
| Auth flows | /sign-in, /register, /reset, /auth/**, /auth/passkey/*, /auth/site-sso[/callback] | varies | :auth, except POST /auth/*/password/register, which takes :register instead so the two registration doors agree (#724) |
| Second factor | GET/POST /sign-in/verify | signed :pending_2fa token + TOTP or recovery code | :auth; the POST also per-account, tighter than sign-in (#714) |
Credential submits over /live | LiveView "submit" on the sign-in, register, reset-request and magic-link forms — all four render on all three auth pages | credentials → session / account / mail | charged on the action, since no plug can reach them: sign-in :auth (#715) + per-account (#478); registration :register (#724); reset and magic-link :auth (#724) + the per-address mail budget |
| Editor / admin LiveViews | /editor/**, /media | session cookie + role | none, except the three TOTP actions on /editor/settings: per-account, the second factor's own bucket (#727) |
| Media bytes | /media/:id/download, /media/:id/stream | session (a gated item needs its audience) | :delivery |
| Image transforms | /media/:id/t/:ops | session (same read as the download); unsigned URLs allowlisted, signed ones HMAC-checked | :media_transform per request + :media_render per cache miss, plus a per-node render gate |
| Media blobs | /uploads/* (Plug.Static) | none | none |
| Sockets | /live, /ws/collab, /ws/bridge | session / signed token + per-document read / preview token (one document, re-verified until it expires) or API key + per-document read | /live root joins :live_join per address (#1183); every frame on a /ws/collab connection :collab_event per account (#1305); otherwise none (except the sign-in submit, above) |
| Dev tools | /dev/dashboard, /dev/mailbox, /admin, /gql/playground | compile-gated off in prod | — |
/ws/collab is a prototype surface. Its joins are refused unless
config :kiln_cms, :collab_prototype is on, and it is on only in
config/dev.exs and config/test.exs (config/prod.exs pins it false,
#1660) — so a production build carries the socket but accepts no CRDT session
(#1324, and collaborative-editing-spike.md). Everything
below about the collab room is modelled as if it were live, because that is the
bar it has to clear before it can be enabled; it is not a live surface today.
The server-side Ash policies are the authorization boundary. Every read and
mutation through GraphQL, JSON:API, REST, MCP and LiveView runs through
Ash.Policy.Authorizer with the request's actor and tenant. The API layers add
no authorization of their own; they inherit the resource policies. There is no
"the API is trusted" shortcut. test/kiln_cms/policy_coverage_test.exs fails the
build if a resource is ever registered without that authorizer.
Controls in place
Authentication — AshAuthentication: password (bcrypt), magic link (which deliberately does not self-provision), API keys, and optional OIDC SSO; plus TOTP 2FA and Wax-based passkeys/WebAuthn. Short-lived JWTs with a token store, and every stored token revoked on a password change or reset (
KilnCMS.Accounts.Changes.RevokeAllTokens, #734).Authorization — per-resource
policies, field policies hidingroleand author PII, astate == :publishedfilter as the public-read boundary, the orthogonal audience axis, granular per-type and per-field editor grants, and block-leveleditable_byfield policies (below). Backed by the policy test suite andpolicy-matrix.md.API-key scoping — keys store only a SHA-256 hash, carry an immutable
:read/:read_writeaccess scope, require an expiry, and can be revoked. A:readkey is refused every write action; no key may hard-delete.Multi-tenancy —
Plugs.SetTenantresolves the org from the HTTP host (subdomain ofTENANT_BASE_HOST, then custom domain) and sets it as the Ash tenant for the whole request, so tenant scoping applies to GraphQL and JSON:API without resolver changes. A host matching neither falls back to the default org unless strict host matching is on —TENANT_STRICT_HOST=true, or unset on a deployment with more than one org (#1547) — which 404s it instead; see residual risk 3.Rate limiting —
Plugs.RateLimit(per-IP) across nine buckets; limits inlib/kiln_cms_web/rate_limit.ex. The credential buckets (:auth,:register,:unlock) and everyAccountThrottlebudget count in Postgres (KilnCMS.Accounts.ThrottleStore, #1619), so they hold across nodes and restarts; the rest are flood ceilings counted per node in ETS. The credential forms submit where no plug can reach them: each is an AshAuthentication LiveComponent callingAshPhoenix.Form.submit/2in-process, so the credentials arrive as a/liveevent and pass no pipeline. (auth_routesalso generates a POST route per strategy action as the non-JS fallback; those are plug-reachable, which is why the registration one is charged:registerthere too — seeKilnCMSWeb.Plugs.AuthRateLimit.) And all four — sign-in, register, reset-request, magic-link — render on all three of/sign-in,/registerand/reset, hidden from each other only by a CSS class, so which page a caller is on bounds nothing.They are charged on the action instead (#715 for sign-in, #724 for the other three) —
KilnCMSWeb.SignInLiveattaches the socket's own client address (:peer_data/:x_headers, resolved through the same trusted-proxy rulePlugs.ClientIpapplies) to the form's context, andPreparations.ThrottleSignIncharges it on the action. Same bucket as the HTTP form, so switching transport buys no second budget; charged only when that context is present, so a request that already paid the plug is not charged twice. Every one of these charges from abefore_actionhook rather than from theprepare/changebody, because those run per changeset build andAshPhoenix.Form.validate/2builds one per keystroke on aphx-changeform — a charge there would lock a user out while they typed.Registration gets its own
:registerbucket rather than a share of:auth(#724): it was the unbounded one, at a bcrypt hash and a confirmation mail per socket event, but sharing would let a burst of legitimate sign-ups lock sign-in for everyone behind one office NAT — the shared-NAT trade residual risk 5 records. It carries no per-account budget, because there is no account yet and the address being registered is attacker-chosen: keying on it would let anyone deny a specific address its first registration. Password sign-in is limited on a second axis byKilnCMS.Accounts.AccountThrottle(#478): a flat per-account budget, which IP rotation cannot escape (twenty attempts per fifteen minutes — see the #762 note below on why it is not ten). The IP is charged first and a refusal spends no account budget — otherwise a flood from one address could lock out every account it named. Deliberately flat rather than escalating — a lockout that lengthens each time an attacker burns a window is a denial of service against any known address. A successful sign-in, a completed password reset and a passkey sign-in each clear it. A separate flat per-address budget covers the two mail-triggering requests (password reset, magic link) so neither becomes a mailbomb.The second factor carries its own, tighter per-account budget (#714): five submitted codes per fifteen minutes at
POST /sign-in/verify, keyed on the account the signed pending token names, with a verified code clearing it. Tighter because six digits and a skew window are guessable in a way a password is not, and because whoever is at that prompt has already got past the first factor. It keys on the account rather than on the pending token for the same reason: the pending token's five-minute life bounds nothing on its own, since re-running the password step mints a fresh one. Since #742 that renewal costs a unit of the sign-in budget rather than being free — it no longer clears on a password that stops at the code prompt — so the two budgets now compose rather than one cancelling the other. TOTP codes and recovery codes share the budget; two would be one budget twice as large. This refusal is a plain 429 that says so, unlike every other refusal in the auth flow: the account is already known to whoever is asking, so there is nothing to hide, and a generic "that code isn't valid" would tell a legitimate user their correct code was wrong.Since #742 the sign-in budget and the second-factor budget compose: a password that stops at the code prompt no longer clears the first, so an account whose second factor is locked out spends first-factor budget on every retry — and both controllers tell a refused user to do exactly that. The owner can reach this state alone, because #727 shares the second-factor bucket with
/editor/settings: fumble five codes regenerating recovery codes, then retry the password, and the first factor can lock too for the tail of its own window. A password reset clears both and is the remedy to offer.#762 pulled that lever: the sign-in budget is now twenty per fifteen minutes, not ten. Twenty keeps an attacker bound in the same order of magnitude — still not unlimited guesses, still one window's tail at worst — while putting the self-inflicted compounding case out of practical reach, since it now takes twenty password retries inside one window rather than ten. The alternatives were rejected as riskier than moving a number: refunding the first-factor unit when the second factor refuses reopens the unbounded token-minting loop #742 exists to close, and a "hand back one unit" primitive would mean
hit/3is no longer one atomic increment-and-compare, which is what stops a simultaneous burst all reading "under budget" and all proceeding.A lockout at either sign-in gate mails the owner (#728), and it is a much stronger signal than the password alert above: reaching that prompt requires a pending token, and a pending token is only minted once a first factor has already succeeded. That case used to be structurally invisible to the password alert — sustaining the grind means re-running the first factor, which succeeds and forgave the sign-in counter every time, so its budget was never reached. Before #728 the one case where a primary credential was provably in someone else's hands produced no notification at all. #742 closed that reset, so the password alert can now fire on the same attack too; this one still rings first, because the second-factor budget is the tighter.
The copy is careful about two things the obvious wording gets wrong. It does not say "someone has your password":
AuthController.success/4is the callback for every strategy, so a magic link or an OIDC assertion reaches the prompt the same way, and for those users the compromised credential is a mailbox or an IdP — the mail names all three rather than sending them to secure the wrong account. And it does not assume an attacker, because the budget is shared with the settings forms (#727), so an owner who fumbles codes there and then signs in trips it with nobody attacking them. Its once-per-six-hours budget is separate from the password alert's, so the weaker signal cannot suppress the stronger one in exactly the order an attack produces them; the refusal is logged when the mail goes and when it is suppressed; and a delivery failure hands the window back rather than swallowing six hours of alerts with it. Watch: a lockout confined to/editor/settings— no sign-in attempt after it — still notifies nobody (#757); different news, because the person there holds a session rather than a first factor.CSP & secure headers —
put_secure_browser_headersplus a per-request nonce-based Content-Security-Policy on browser pipelines; a narrower static policy for preview/forms/embeds and a relaxed one scoped to the Swagger explorer. Sobelow checks CSP placeholders in CI.CSRF —
protect_from_forgeryon browser and LiveView pipelines. Token APIs are cookieless (bearer only) and so are not CSRF-exposed. Public form submission and RFC 8058 one-click unsubscribe are deliberately CSRF-free; see the per-surface notes.CORS — Corsica, scoped to
/apiand/gqlonly, with an exact-string origin allowlist that defaults to deny in production and noallow_credentials. Browser pages stay same-origin.GraphQL abuse limits — one document pipeline for
/gqland/ws/gql(KilnCMSWeb.GraphqlLimits). Every document gets complexity analysis with a cap of 200, a depth limit of 15 and a token limit of 2,000. These are pinned where the pipeline is built, because the socket's Absinthe options are replaced after its first document and a plug can override the HTTP ones. To-many relationships without alimitare priced at five rows each, so a relationship cycle (relatedPosts,featuredImage { featuredPosts }) cannot nest for free. A batched/gqlbody may carry 10 operations at most, and each is charged to:gql(KilnCMSWeb.Plugs.GraphqlBatchLimit). Introspection is refused in production by a pipeline phase that reads the parsed document, so a batched body and a socket document are checked like a single query. Each document sent over/ws/gqlis charged to:gqltoo, under the address the socket connected from (KilnCMSWeb.GraphqlLimits.SocketDocumentBudget); a subscription's pushes are not. Until 2026-09 the cap applied only to single/gqlrequests: the socket had no limits, a batch was one request whatever it carried, a batched body got past the introspection block, and a socket could send any number of documents once connected.HTTPS / HSTS —
force_sslwithx_forwarded_protorewriting inconfig/prod.exs.Session cookies — signed and encrypted,
SameSite=Lax,http_only, andsecurein production. Whereversecureis on, the cookie is also__Host--prefixed (__Host-_kiln_cms_key), which is what makes it host-scoped for writes as well as reads: orgs are siblings under one registrable domain, and RFC 6265 otherwise lets script on any tenant origin set a same-named cookie for the parent domain. It would then outrank the victim's own — Plug honours the header's first cookie of a name, and RFC 6265 §5.4 sends longerPaths first, soDomain=.<base_host>; Path=/editorwins on exactly the authoring routes. Browsers refuse to store a__Host-cookie that is notSecure,Path=/, andDomain-less, so the sibling's write is rejected at the source; the prefix rides the same flag asSecure, and dev, test and e2e keep the bare name over plain HTTP (KilnCMSWeb.SessionCookie, #686).The remember-me cookie is inside the same rule (#699), and had to be: it is the better credential of the two — a 30-day token rather than a browser session — and
sign_in_with_remember_meruns ahead ofload_from_session, so planting one signs a visitor in who has no session on the target host at all. AshAuthentication's default writer hardcodessecure: Mix.env() != :devand leaves the name unprefixed, soKilnCMSWeb.AuthControlleroverrides both the writer and the deleter; both take their attributes fromSessionCookie.remember_me_options/1and the name fromremember_me_key/1— which is also what the read path keys on, since the strategy'scookie_nameis set from it. Sign-out deletes it through the same override, so the two sides cannot drift into a deletion the browser will not match.It is read on
:browser_authonly, never on:browser. Signing a visitor in writes the session, soPlug.SessionemitsSet-Cookie— and:browserserves public delivery pages markedpublic, max-age=60, where a shared cache would be free to store one editor's session cookie against a public URL. A remembered visitor who opens an authoring URL is redirected to/sign-in, signed in there, and sent on.Residual: signing out revokes only the token in the browser doing it, so a cookie copied elsewhere keeps working until it expires — or until something revokes every stored token for the account. Two things do: a password change or reset, and an administrator's Sign out everywhere on the account's page under
/editor/accounts. The password actions do it through an explicitKilnCMS.Accounts.Changes.RevokeAllTokens, inside the write's transaction — the password is not changed if the revocation fails. Not throughlog_out_everywhere'sapply_on_password_change? true, whichKilnCMS.Accounts.Userdeclares but which never fired: its change is gated onhashed_passwordbeing touched, evaluated when the changeset is built, and both actions write the hash in abefore_action. Until 1.0 that made this control a claim rather than a fact, which is how an earlier revision of this page came to say it held "since #734" (fixed properly in #734's reopen, with the socket half in #1637). The session on the device that changed the password is revoked too; the settings page sends the user to sign in again. Since #1823 an account holder can also do it themselves: Active sessions on/editor/settingslists their own sessions and signs out one, or every one but the current, throughKilnCMS.Accounts.Sessions. That revokes the session's token, the remember-me cookie issued to the same browser (its jti is recorded on the session row at sign-in), and closes that session's open LiveViews (SessionEviction.evict_session/2). It does not reach a/ws/collabor/ws/gqlsocket already connected from that browser: those are keyed per user, and close at their next reconnect or re-check.Remember-me and the second factor. The cookie is a completed sign-in in a cookie — the read plug hands it to
store_in_session/2directly, so it never passesAuthController.success/4and never reaches the 2FA diversion. Since AshAuthentication issues it inPlug.Dispatcherbeforesuccess/4runs, a 2FA account would otherwise be handed a 30-day credential having proved only its password: tick the box, abandon the code prompt, and the second factor is gone entirely. Sosuccess/4withholds the cookie on the diversion and carries the intent in the pending token;TwoFactorControllerissues a freshly minted one once a code verifies (a fresh mint rather than the withheld token, to keep a 30-day credential out of the five-minute pending blob). That the resulting cookie then signs the user in later without a code is the deliberate part — it is a "this device completed every factor" credential, and that is what remember-me is for on a 2FA account.SSRF protection on outbound calls —
KilnCMS.Webhooks.SafeUrl: HTTPS required in prod, private/loopback/link-local/metadata ranges rejected for both IPv4 and IPv6, DNS resolved with an all-or-nothing rule and a hard timeout. Callers connect to the pinned resolved IP with SNI and cert validation kept on the original hostname, closing the DNS-rebinding window, and follow no redirects — a followed redirect is a fresh resolution the pin never sees.KilnCMS.SafeFetchpackages that plus a streaming byte cap, since an attacker-influenced response has an attacker-influenced length too. Since #753 there is exactly one implementation: every caller that fetches a URL the content chose — webhook delivery, oEmbed, link checking, federation, social posting, portability import, Unsplash import (#1653) — goes throughSafeFetch. A new caller reaching forReqdirectly, or copying itsconnect_options, is the bug that invariant exists to catch.Upload handling — uploads validated from bytes rather than declared type, EXIF stripped, and blobs served with
Content-Disposition: attachmentandX-Content-Type-Options: nosniff.Static analysis & dependencies — Credo, Sobelow, Dialyzer, and two dependency audits (
mix deps.auditagainst mirego's advisory mirror,mix hex.auditagainst Hex's own feed) in CI andmix precommit, failing the build on a known-vulnerable locked dependency.
Per-surface risks & mitigations
Public content delivery
- Draft leak — mitigated by the published-state filter on public reads, covered by policy tests. Watch: any new public read action must carry the same filter.
- Audience leak — audience-restricted records require reader membership.
Resolved (#337 Phase 2): the delivery payload cache is the anonymous,
:public-only shape, and audience-aware renders (a member's full document, and the paywall teaser) bypass it entirely rather than the key gaining an audience axis — so a gated payload is never in the cache to be mis-keyed. Those responses areprivate, no-storewithVary: Cookie, since the public delivery headers (public, max-age=60) would otherwise let a shared cache serve one member's gated render to every anonymous visitor. - Scraping / enumeration — content is public and the sitemap is intentional.
The
:deliverybucket caps volume; front with a CDN to absorb load.
Image transforms (/media/:id/t/:ops)
- Resource exhaustion — every distinct parameter set is a decode, a resize
and an encode, and the route is anonymous. Bounded in layers: unsigned URLs
may only use an allowlist of sizes, ratios and qualities (a signed URL may use
any value, and signing needs
KILN_IMAGE_TRANSFORM_KEYorSECRET_KEY_BASE); every output side is capped at 4000px and every source at the upload pixel cap, the latter checked from the recorded dimensions before decoding; cache misses spend a per-IP:media_renderbudget and wait on a per-node concurrency gate; and an item keeps at most 200 derivatives, after which renders are served but not stored. Parameters and signatures are checked before the item is read, so refusals cost no I/O. Watch: behind a proxy or CDN that isn't configured as trusted, every client shares the proxy's address and so one render budget — the same caveat as every per-IP bucket. - Signing-key exposure — a leaked
KILN_IMAGE_TRANSFORM_KEYlifts the allowlist, not the hard caps or the render gate. Rotating it invalidates signed URLs already in pages. It must stay server-side; the SDKs say so. - Gated media — the route reads the item with the request's actor through
the same policy-checked read as
/media/:id/download, so a gated or quarantined item is a 404 there too, and a gated item's derivatives live in private storage and are servedprivate, no-store.
GraphQL / JSON:API / REST
- Authorization bypass — prevented by Ash policies running with the request actor and tenant; there is no unauthenticated mutation path that skips them.
- Write surface —
/api/jsonaccepts POST/PATCH/DELETE including publish/unpublish (#330). Gated by resource policies and the API-key access scope, not by the router.destroyis a soft delete;purgeis never routed. - Mass assignment — Ash actions accept only declared inputs (
accept). - Query cost — each document is capped at complexity 200, depth 15 and
2,000 tokens on both transports, and a batch at 10 operations. Complexity is
a price, not a row count: a relationship list without
limitis priced at five rows and can return more, so the cap limits how deeply lists nest rather than how many rows one document returns. Each document on/ws/gqlis charged to:gqllike a/gqlrequest (residual item 11). Introspection is off in production. - Error verbosity — keep
:loggerat:infoin prod (already set).
MCP (/mcp)
- Unauthenticated tool use — prevented: the pipeline requires an API key (401 without one). The tool allowlist is compile-time and deliberately omits publish and destroy, so an LLM client can author and submit for review but cannot publish or delete.
Headless sign-in (POST /api/auth/sign_in)
- Credential stuffing / brute force — mitigated on two axes: the per-IP
:authbucket, and the per-account budget inKilnCMS.Accounts.AccountThrottle(#478), which an attacker rotating source addresses cannot escape. Bcrypt cost applies to both — including to a throttled attempt, which burns the same simulated hash, so response time doesn't reveal that an address is currently at its budget. Failures return a generic 401 and so does a throttled attempt; the budget keys on the submitted identifier, so an address with no account throttles identically and the refusal is not an enumeration oracle. The account owner is mailed once per window when attempts start being refused. - Skipping the second factor — closed (#726). This endpoint used to return
a full JWT for a 2FA-enabled account on the password alone, where the browser
flow diverts to
/sign-in/verify. That made TOTP optional in practice rather than in policy: there is no point bounding six digits at one prompt while a door next to it does not ask. A 2FA account now gets200with a pending token instead of201with a JWT, and finishes atPOST /api/auth/sign_in/verify.What is withheld is access to the token, not its existence.
Strategy.action/3mints the JWT and — becauseUsersetsstore_all_tokens?— stores it, before anything looks attotp_enabled?. The second factor gates whether the caller ever receives it. Say it that way round: "no token is issued" would tell an incident responder that a password-alone compromise leaves nothing to revoke, and it leaves something.Since #742 the row's use is withheld too, on both doors.
PendingSignIn.mint_and_hold/4moves it to thepending_second_factorpurpose and shortens its expiry to the length of the step, andclaim/1puts it back once a code verifies.AshAuthenticationrequires a row under theuserpurpose to authenticate a JWT (require_token_presence_for_authentication?), so between the two steps the token authenticates nothing, wherever it is — and an exchange that is never finished leaves an inert row that expires with the step rather than a live credential nobody holds for the JWT's full lifetime. The rate was already bounded: #742's first half stopped a password that stops at the code prompt from clearing the per-account sign-in counter, so an attacker gets@budgetof them per window per account rather than as many as their IP pool allows.Held, not revoked:
:revoke_jtiwrites a thousand-yearrevocationrow, and the exchange may still complete. The filter cuts the other way too — a release only ever moves a row off the hold purpose, and that predicate is in the UPDATE's own WHERE rather than checked against the row the caller read, so a revocation landing mid-release is not overwritten rather than merely usually surviving. A password change or reset (RevokeAllTokens, #734) and account erasure sweep every row a subject owns, held ones included; sign-out revokes only the token held in that session, which a browser waiting at the code prompt does not have.Residual: a hold that cannot be written does not fail the sign-in — it logs and leaves the pre-#742 behaviour, because refusing instead would turn a token-store hiccup into "no account with a second factor can sign in". The token is still minted; what is closed is that it is usable.
The pending blob is encrypted (
Phoenix.Token.encrypt/4), not signed. The browser's equivalent can be signed because it lives in the encrypted session cookie; this one is handed to the client, and signing it would publish the first-factor JWT it carries in a decodable payload — reintroducing the bypass in a form that looks fixed. It follows that the blob is itself a credential and has to be handled as one;docs/api.mdsays so to integrators, because "opaque" reads as "harmless" otherwise.It is single-use: a completed redemption is recorded as spent, so a captured verify request cannot be replayed — and a successful request is the one most likely to be sitting in a log, a CI transcript or a crash report. The browser flow gets this by deleting the session key. A wrong code or a spent budget does not burn it, because neither is a failed authentication. Single use is exact, on one node and on a cluster (#743): the record is a
KilnCMS.Accounts.Tokenrow whose primary key is the blob'sjti, so the INSERT is the check and two redemptions of one blob race at Postgres. The loser is refused rather than issued a token.It was a node-local
Cachexentry, which failed open across nodes — a replay landing on a node that never saw the redemption was accepted — and, less obviously, could hand out two tokens for one blob on a single node when both requests resolved before either recorded. Nothing rejects a reused TOTP code, so the two only had to arrive together.WebAuthn.take_challenge/1still makes the node-local trade for its own state.AccountThrottleno longer does: since #1619 its budgets live in Postgres (residual risk #10 below, closed).Codes are charged
AccountThrottle.consume_second_factor/1on the same per-account bucket the browser prompt charges. Per-surface budgets would let an attacker double their guesses by alternating endpoints, and the five-minute pending lifetime bounds nothing on its own — re-running the password step mints a fresh token. Since #742 each of those costs a unit of the sign-in budget, so the renewal is bounded rather than free. The bucket is shared by every node (#1619, residual risk #10 below), so the ceiling is 5 per window however many nodes serve the endpoint. Residual: reaching that bucket used to require a browser session and a CSRF token. It now takes fivecurlcalls from anyone holding the password, and because the bucket is shared it locks the owner out of both surfaces for the window — the denial-of-service #478 chose a flat budget to avoid, arriving by another route. The flat window still bounds it to one window's tail rather than an escalating lockout.two_factor_requireddiscloses that an account has a second factor, but only to a caller who has already supplied the correct password — which the browser flow discloses just as plainly by redirecting to the prompt. Every refusal before that point is still the same generic 401, and costs the same bcrypt.
- Passkeys as a side door — a verified passkey completes sign-in with no
TOTP diversion (
KilnCMSWeb.PasskeyController), and that is policy, not an oversight: every Kiln passkey is registered and asserted with user verification required, so the ceremony proves possession + PIN/biometric — the bar the TOTP flow enforces. It is also browser-only; there is no headless passkey route, so it is not a second door onto this surface. - Token theft — JWTs are bearer tokens; clients must store them securely and use TLS. Tokens are revocable via the token store.
Editor / admin LiveViews (/editor/**, /media)
- Router gates skipped by a url-less join — a
/livejoin whose payload carries neither"url"nor"redirect"matches no route, and Phoenix only attaches alive_session'son_mounthooks when a route matched. So such a join runs none of the tier gates. The credential is the signeddata-phx-sessionblob scraped from any page the caller was served, which outlives both the visit and a later demotion. Mitigated (#688):KilnCMSWeb.LiveRouteGuardis declared byuse KilnCMSWeb, :live_view, so it is attached to the view rather than to thelive_sessionand runs anyway; it refuses a connected join that matched no route and whose session names alive_sessionas a 404, which the channel turns into a client reload rather than a crash. (Thelive_sessionhalf is load-bearing: a stickylive_renderchild is a "main" session that legitimately carries no URL, so refusing on "root with no route" would put it in a reload loop.) A test walks everyliveroute in the router and fails if its view does not carry the guard, which is what enforces it for plugin panels rather than assuming it. Watch: third-party LiveViews keep the framework behaviour — AshAdmin's are compile-gated to:dev_routes. AshAuthentication's are now all routed through thin Kiln wrappers, so they carry the guard like everything else:/sign-in,/registerand/resetthroughKilnCMSWeb.SignInLive(#715), and/password-reset/:token,/confirm_new_user/:token,/magic_link/:tokenand/sign-outthroughKilnCMSWeb.AuthLive(#701). Before that last one those four skipped:assign_current_orgon a url-less join and rendered with the default org's branding on a tenant host — no authorization was reachable that a signed-out visitor could not reach anyway, but the identity on the page was another tenant's, the leak #48 exists to prevent./sign-outis worth calling out because it is the one that reads as controller-only:sign_out_route/3emits aDELETEto the auth controller and aliveroute in its ownlive_session, and only the first appears at the call site. Its live half had a replayable session like any other.KilnCMSWeb.LiveJoinWithoutUrlTest's exemption list is now empty, which is what keeps this true as views are added. - Session as the credential — the whole surface is gated by the session
cookie plus the per-org effective tier, so the cookie's integrity is the
boundary; see the
__Host-prefix under Controls (#686). - Second-factor codes over the socket —
/editor/settingsverifies a TOTP code fordisable_totp,regenerate_totp_recovery_codesandconfirm_totp. A LiveView event passes no router pipeline, so none of them got the per-IP:authbucket: a stolen session could push the event in a loop and grind 10^6 at socket speed. On a hitdisable_totpremoves the second factor outright, and either of the other two hands over a working recovery-code set. Mitigated (#727): all three chargeAccountThrottle.consume_second_factor/1— five per account per fifteen minutes, the same bucket/sign-in/verifyuses, so they cannot be spent independently. The charge lives on the Ash action rather than in thehandle_eventclauses, so a future caller inherits it. Watch: the bound holds across nodes since #1619 (residual risk 10), and it bounds guessing only. It hands a stolen session a small denial-of-service it did not have: five wrong codes here deny the real owner/sign-in/verifyfor the rest of the window. That is strictly less than what the session already grants, so the trade is accepted. setup_totpas a second, code-free door to the same removal —confirm_totpwas never scoped to an enrolment in progress, so on an already -enrolled account it checked the account's live secret; a session that calledsetup_totpfirst got a fresh secret written straight intototp_secretwithtotp_confirmed_atnulled in the same call — turning 2FA off with zero code guesses, no budget charged, and nothing telling the owner the account had stopped asking (#754). Mitigated (#754):setup_totpnow stages the new secret into a separatetotp_pending_secretattribute and touches nothing else; onlyconfirm_totp— checked against the pending secret and still budgeted (#727) — ever promotes it tototp_secretand stampstotp_confirmed_at. Enrolling (or re-enrolling) can therefore never by itself disable an existing factor. Watch: a session that completes bothsetup_totpandconfirm_totpwith a code of its own choosing still replaces which secret backs the account's 2FA —totp_confirmed_atnever goes false, but the owner's own authenticator silently stops working. That swap is not new here (the pre-fixsetup_totp+confirm_totppair could reach the same end state, just via a moment where 2FA visibly dropped) and is tracked separately rather than folded into this fix.
Public forms
- CSRF — deliberately absent: forms are meant to be posted from third-party
pages. Abuse is bounded by the
:formbucket and a honeypot field, and a tripped honeypot returns success so a bot cannot distinguish rejection. - Framing — the embed route sets its own
frame-ancestorsfrom the form'sembed_origins(#648), falling back to the deployment-wideEMBED_ORIGINS, which defaults to same-origin only (#562), so cross-site embedding is opt-in. Per form because a deployment-wide allowlist has to be the union of every org's embedders, and that union is what every org's forms become framable by. A form's list is written by an org admin — the party whose submissions an overlay would harvest — and grants nothing across the tenant boundary. - Submission contents — treat as untrusted PII; retention is covered in
data-flows.md.
Preview
- Token as credential —
/preview/:tokenverifies a signed token and then loads the record withauthorize?: false, so token possession is full read access to that record in whatever state it is in. Tokens are the sharing mechanism for unpublished work; treat a leaked preview URL as a content leak. See residual risk 6. - Headless draft mode (#1887) — the same token on
/api/jsonand the fired-artifact read. It does not bypass authorization there:KilnCMSWeb.Plugs.PreviewGrantverifies it, pins it to the serving site and puts a grant in the Ash context, andChecks.PreviewGrantaddsid == grant.idto the content read policy for the primary read only. The published-only actions, search and every write are unchanged, and an edge to another draft stays hidden (LinkEndsReadable). A presented token that does not verify is a 404, never a fall back to the anonymous read. A write never reads the token. Responses areprivate, no-store; every response of an honouring routeVarys on the header; the query-string form is filtered from the request log, and Sentry events drop the header and mask the parameter (KilnCMSWeb.SentryScrubber).
Media (/uploads/*)
- Unauthenticated access — local blobs are served by
Plug.Staticwith no auth, no rate limit and no tenant check. Storage keys are unguessable UUIDs, which is the only thing standing between an unpublished asset and the world.Content-Disposition: attachment+nosniffprevent the bucket being used to serve active content. S3/MinIO deployments serve media entirely outside the app. Accepted by design (security audit 2026-09-27, finding 15, #1666): a public media URL is a capability URL. Gated documents and A/V live in private storage, which has no static mount and is served only through the policy-checked download and stream routes. Operator guidance is in media-pipeline.md and deploy.md.
Webhooks (outbound)
- Gated content is delivered — a content event carries the full block tree
whether the document is public, audience-gated, or passphrase-locked. That is
deliberate: an endpoint is operator-configured, HMAC-signed and SSRF-guarded,
unlike the anonymously-queryable Meilisearch index, which excludes gated
content outright (#1006). The payload marks both gates (
audience,locked, #1014) so a receiver can filter — but the filtering is the receiver's, and a webhook endpoint's blast radius is therefore every document that fires an event, not only the public ones. Treat an endpoint URL as a credential. - SSRF — mitigated by
SafeUrlwith IP pinning (see Controls). - Forgery at the receiver — deliveries are HMAC-SHA256-signed; a receiver
that verifies
x-kilncms-webhook-signatureknows a delivery is genuinely from Kiln, with unmodified content, and sent within the tolerance window it enforces (five minutes by default), because the timestamp is inside the MAC. Inside the window a receiver dedupes on the signeddelivery_id. The older body-onlyx-kilncms-signature, which proved origin and integrity but not freshness, is no longer sent (0.12.0, #1616). See residual risk 15 and webhooks.md. - Secret disclosure — each endpoint's signing secret is vault-encrypted at
rest, so a database dump, backup or replica does not hand out the ability to
sign deliveries. The trade-off is the vault's: rotating
SECRET_KEY_BASEorphans the secrets, and each endpoint must be re-created (secrets-rotation.md).
oEmbed resolution (OEMBED_ENABLED, #489)
- Content choosing the destination — prevented by design. Kiln does not
use oEmbed discovery, which would mean fetching the embedded page and
following a
<link rel="…oembed">— i.e. letting a field any editor can type decide which host the server dials, with its egress IP. Endpoints are constants inKilnCMS.OEmbed.Provider; the URL only selects which of them is asked, and a URL no provider claims produces no request at all. - The dial itself —
KilnCMS.SafeFetch(pinned, no redirects, 64KB cap). Belt and braces given the endpoints are constants, but a provider's DNS is not, and "the endpoint is hardcoded" is the assumption that makes a laterOEMBED_PROVIDERSchange quietly dangerous. - Provider HTML — discarded, not sanitized. An oEmbed response carries an
htmlfield of provider-authored iframe/script markup; rendering it means trusting a third party with script execution on the delivery origin. Cards are built from escaped scalars, and the canonical-iframe rewrite for YouTube and Vimeo remains the only thing that emits an<iframe>. - Thumbnails — checked against that provider's own CDN hosts, on resolve
and on any write, because the metadata fields are ordinary block scalars an
editor or a headless caller can set directly.
img-srcwidens to exactly that list, and only while the feature is enabled. - Cost amplification — a save containing an unresolved embed costs one outbound request. Bounded by the resolve being enqueued only when a provider claims the URL and the block has no title yet, so a resolved document does not re-fetch; and by Oban's per-document uniqueness window.
A site's own AI provider (/editor/site-ai, #1557)
A site admin — a tenant, on a hosted deployment — can point the site's SEO
suggestions, block assist and /api/ask answers at their own provider account.
That makes the site admin the one choosing where this server sends content and
a credential, so the operator's trust assumptions do not carry over:
- The destination — a closed list of hosted providers, each dialled at the
provider's own published API root, or one
https://OpenAI-compatible URL. That URL is SSRF-checked at save (Validations.AiBaseUrl) and dialled only throughKilnCMS.SafeFetch(re-checked and pinned per request, no redirects, 1MB response cap), never throughreq_llm's own client.ollamaandvllmare not offered: their default endpoint islocalhost, the operator's box. - Exfiltrating the operator's secrets — the key is database-only, with no
env-var or file source, so it cannot be pointed at
SECRET_KEY_BASEor the operator'sANTHROPIC_API_KEY. And becausereq_llmfills an unset key or endpoint from the operator'sconfig :req_llmand<PROVIDER>_API_KEY, a site request always passes both explicitly (an absent key is sent as"", whichreq_llmrefuses rather than fills).SiteProviderIsolationTestplants the operator's key and endpoint in every placereq_llmreads. - Exfiltrating the site's own key — write-only in the form, and dropped
when the provider or endpoint changes, so a co-admin who was never shown the
key cannot redirect it to a host they control. Vault-encrypted at rest;
a
SECRET_KEY_BASErotation makes it unreadable (secrets-rotation.md). - Falling back — a site whose provider is set but unusable (unreadable
row, undecryptable key) is refused, never served by the operator's provider.
Falling back would send its content through an account and DPA it opted out
of, billed to the operator. See
KilnCMS.LLM.SiteProvider. - Cost — the
KilnCMS.LLM.Budgetbuckets apply to a site's key as to the operator's, so/api/askstays rate-limited per caller and per site; each call still occupies a process here for up to the feature's timeout. - What the provider sees — the same as the operator's provider would: a page's text, a block and the editor's instruction, or published passages and an anonymous visitor's question. It is the site's choice and the page says so.
Other outbound calls
Kiln.Updates (the kilncms.dev release feed, then GitHub releases;
admin-triggered, and the feed's links.next is followed only on its own
origin),
Meilisearch (the operator's instance — a site's own is below), S3/MinIO, the
mailer, and the LLM providers behind /api/ask and
SEO drafting all make outbound requests to operator-configured or fixed
endpoints, not user-supplied ones — so they are not SSRF vectors in the way
webhooks are. The exceptions are a site's own SMTP relay (#1322) and AI
endpoint (above), which are tenant-chosen and SSRF-checked, and
KilnCMS.Unsplash: its API host is fixed, but the image URL an import downloads
comes from Unsplash's response, so every Unsplash request goes through
SafeFetch — pinned, each redirect hop re-validated, and capped at the image
upload ceiling (#1653). Note that /api/ask lets an anonymous caller drive an outbound
LLM request; it is config-gated and rate-limited under :api, but it is a cost
amplification surface.
ActivityPub inbox (POST /actor/inbox, #491)
Off unless both the deployment (KILN_FEDERATION_ENABLED) and the site turn it
on. Every activity the inbox acts on must carry an HTTP Signature, and the key
that verifies it lives in the sender's actor document — so authenticating costs
an outbound GET to a URL the unauthenticated caller named. That fetch is kept
behind everything that can be decided without it (#1665): only a Follow or
Undo{Follow} addressed to this site's actor needs one at all, and even that
one is refused with no request made unless the Signature header parses,
covers (request-target) host date digest, carries a Date inside the
five-minute window and a Digest matching the raw body, and names a keyId
belonging to the activity's own actor. What remains is the irreducible part —
a well-formed request for an actor URL the caller chose still costs one fetch,
because telling a forged signature from a real one needs the key. That fetch
goes through SafeFetch (pinned address, no redirects, 128 KB cap), is cached
per actor for ten minutes in a capped table, and sits under the route's per-IP
:api bucket. A verified signature is then recorded in the replay store, and
the fetched document must have been served from the URL it claims as its id.
See Federation.
A site's own Meilisearch instance (#1558)
The one outbound integration above whose endpoint a site admin chooses rather than the operator — on a hosted deployment, a tenant. It carries two things out: the site's public content, and a bearer key.
- SSRF — the URL is tenant-supplied, so it is treated like a webhook
target: HTTPS only, no userinfo/query/fragment, and refused if it resolves
to a private, loopback, link-local or metadata address — at save
(
Validations.SearchUrl) and on every request, which goes throughKilnCMS.SafeFetch(resolved once, connected to by address, TLS verified against the name, no redirects, 5 MB response cap). A refused or failed request's status reaches only the job log and the site admin's own page. - Credential exfiltration — the key is database-only. There is no env-var
or file source a tenant could aim at
SECRET_KEY_BASE, and a site's request is built from its row alone: the operator'sMEILI_MASTER_KEY, URL and index never ride along (pinned bySiteInstanceIsolationTest, with operator credentials planted). - Cross-tenant disclosure — the fail direction. A site instance that can't
be used (unreadable row, undecryptable key) never falls back to the
operator's: indexing holds and retries, and
search/2errors so the caller uses Postgres search rather than an index that holds other sites' content. - What leaves — the same public-only documents the operator's index gets (#1006, #496). The settings page says so above the form; add the site's provider to your DPA if you host sites for others.
- Accepted — the site admin chooses who runs the instance, and whoever runs it can read (and alter) what is in it. That is the site's choice about its own public content. A site moving off an instance leaves its documents there.
Object storage
- Credential exposure — S3 keys come from env, never committed.
- Bucket scope — keep the bucket public-read for delivered variants only.
A site's own sign-in provider (/editor/site-sso, #1561)
A site admin can point their site's sign-in page at an OpenID Connect provider they choose. On a hosted deployment that admin is a tenant, the provider is theirs, and accounts belong to the whole deployment — so the provider is treated as able to assert anything, and what it may achieve is bounded by Kiln, not by the provider. See sso.md.
- Asserting someone else's address — a provider is honoured only for email
addresses in a domain the site verified by DNS (a
_kiln-sso.<domain>TXT record carrying a per-site random token), re-checked on every sign-in, exact domains only, and only withemail_verifiedtrue. DNS control implies control of the domain's mail, so this concedes nothing beyond what a password-reset email to that domain already would. - Reaching another site — a site's provider never signs in an account with
access anywhere else: a platform admin (standing or temporary), a member of
any other organization at any tier, or a membership-less account whose global
role reaches beyond the site. Checked at every sign-in, in
KilnCMS.Accounts.SiteSso.Admission. Kiln has no site-scoped session, so the guarantee is made at admission. Accepted: a session such a provider minted before the account later gained access elsewhere keeps working until it ends — no wider than the mail-control concession above. - Pre-registered accounts — an unconfirmed account is never handed to a provider's user (it may have been registered in advance by someone else).
- Identity collisions with the operator's provider — none possible: the
site flow is not an AshAuthentication strategy, writes no
UserIdentityrow, and matches only by verified email. The operator'sOIDC_*provider is unchanged. - Token forgery — Assent's OIDC callback with state, nonce and PKCE, the ID
token checked for
iss/aud/azp/exp, andRS256only:alg: noneandHS*(verifiable with the client secret, which this server also holds) are refused. The flow's parked parameters are bound to the org that started it, single-use, and expire after ten minutes. - SSRF — the issuer must be
https://and passSafeUrlat save; every request the flow makes — discovery, the token endpoint (which is sent the client secret), the signing keys — goes throughKilnCMS.SafeFetch, pinned, no redirects, 256 KB cap. The discovery document must name the same issuer, andhttps://endpoints. - Secret disclosure — the client secret is vault-encrypted, write-only, and
database-only (no env-var or file indirection a tenant could aim at
SECRET_KEY_BASE). A rotation that orphans it makes the site's SSO unavailable; it never falls back to the operator's provider (secrets-rotation.md). - Second factor — a site-provider sign-in completes through
AuthController.success/4, so a TOTP-enrolled account is still asked for its code.
A site's own bucket (/editor/site-storage, #1559)
A site admin — a tenant, on a hosted deployment — can point the site's new uploads at their own S3-compatible bucket. That makes the site admin the one choosing a host this server sends signed requests and file bodies to:
- The destination — the endpoint must be
https://host[:port], is SSRF-checked at save (Validations.StorageEndpoint), and is re-checked and pinned every time a profile is resolved:KilnCMS.Storage.S3.ReqClientconnects to the checked address (SNI and certificate verification on the name, viaKilnCMS.SafeFetch's connect target) and follows no redirect. - Exfiltrating the operator's credentials — the site's ExAws config is
built from the site's settings alone and passed to
ExAws.Operation.perform/2;ExAws.request/2would merge the operator's:ex_awsconfig (session token, endpoint, instance-role lookup) underneath.SiteStorageIsolationTestplants operator credentials and checks no request or presigned URL carries them. The secret is database-only (no env-var or file source), so it cannot be pointed atSECRET_KEY_BASE. - The site's own secret — write-only in the form and vault-encrypted. A blank secret is carried to a new bucket only on the same endpoint, region and access key, so a co-admin cannot aim the stored key at a host of their choosing. (SigV4 never sends the secret anyway; only signatures, scoped to the host they were made for.)
- Falling back — a site whose bucket is set but unusable (unreadable settings or secret, refused endpoint) has its uploads refused. Nothing is written to, or read from, the operator's bucket in its place.
- Cross-site reads — every media row records its profile, and a profile is resolved tenant-scoped to the row's own site, so a row cannot name another site's bucket.
- CSP — the site's public base URL origin (a plain host name, validated)
is added to that site's own
img-srcandmedia-src(KilnCMSWeb.Plugs.SiteStorageCsp); neverscript-src, never another site.
Residual risks
Known and accepted, in rough order of how much they should worry an operator. Each is a deliberate trade-off, not an oversight — but each is worth revisiting.
1.0 review (#1535). Each item below ends with its 1.0 verdict: still
accepted at 1.0, fix before 1.0, or fix after 1.0, with the reason and the
code it was checked against on main (v0.10.0). The maintainer accepted every
verdict on 2026-09-26; item 3 was already settled by roadmap decision 4. Each
fix before 1.0 has an issue on the v0.12.0 milestone. Items keep their numbers
because other files cite them by number.
Form embeds default to
frame-ancestors *. Closed in #562.EMBED_ORIGINSunset now means same-origin only, so cross-site embedding is opt-in, and a malformed value closes the policy rather than widening it.EMBED_ORIGINS=*restores the old any-site behaviour if a deployment genuinely wants it. See forms.md. Remainder, narrowed in #648: the allowlist can now be set on the form, and a form's list replaces the deployment's rather than extending it, so an entry made for one org reaches no other org's forms. What is not closed is the default: a form that has not been given a list still inheritsEMBED_ORIGINS, which has no tenant dimension — so on a multi-org instance the shared union governs every untouched form. Narrowed further in #1131:KilnCMS.CMS.SiteEmbedSettings, resolved byKilnCMS.Forms.EmbedPolicy, inserts a per-org default between the form and the deployment —form.embed_origins -> SiteEmbedSettings.embed_origins -> EMBED_ORIGINS— so an org decides once and every untouched form in that org inherits it, rather than the deployment-wide union. The operator ceiling, closed in #1133, and on by default for multi-org since #1618. On a single-org deployment an org admin can still open framing — on a form, or on the org default — that the operator had left closed; that stays deliberate (the operator and the org admin are the same party, and the allowlist grants nothing across the tenant boundary). Once a second organization exists an unsetEMBED_ORIGINS_LOCKEDturns the cap on by itself (the organization-count verdictTENANT_STRICT_HOSTuses, failing closed when the count is unknown);EMBED_ORIGINS_LOCKED=trueforces it on everywhere andfalsekeeps it off. With the cap on,EMBED_ORIGINSbecomes the most a tenant may open as well as the default: a per-form or per-org list may narrow it but every entry must be covered by it, writes outside it are refused (naming the entry, never the ceiling), and the served header is clamped to it too, so a list saved before the cap cannot keep a page wider than the operator now allows (KilnCMS.Forms.EmbedCeiling). With the cap off nothing changes; withEMBED_ORIGINS=*the cap is a ceiling of everything; withEMBED_ORIGINSunset it is a ceiling of nothing. It also gives the operator the switch an org-admin compromise used to lack: under the cap, a taken-over admin account cannot re-open the overlay-and-harvest surface #562 closed beyond what the operator listed.1.0 verdict (decided, #1535): still accepted at 1.0. Verified on main: the default is same-origin (#562), a form's list resolves form -> org -> deployment in
KilnCMS.Forms.EmbedPolicy(#1131), and the operator ceiling isKilnCMS.Forms.EmbedCeilingbehindEMBED_ORIGINS_LOCKED(config/runtime/cross_origin.exs, #1133). What remains is a stated choice: an org admin decides who may frame that org's own forms, which grants nothing across the tenant boundary, and an operator who disagrees has a switch. Decided alongside #1547:EMBED_ORIGINS_LOCKEDalso defaults on once a second organization exists, so on a multi-org install the operator'sEMBED_ORIGINScaps every tenant unless the operator says otherwise (#1618).Passphrase-locked content is weak by construction (#496). A shared secret typed into a public form is not access control in the sense the rest of this document uses the phrase: there is no per-reader identity, so no audit trail and no way to revoke one reader; the passphrase is chosen by an editor for convenience and is therefore short and guessable far more often than a password is; anyone who has it can pass it on, and you will not know.
The mitigations are bounding, not eliminating. Guessing is bounded by a tight dedicated rate-limit bucket (
:unlock, 10/min per IP, separate from:authprecisely because there is no account to lock out instead). Grants expire in 12 hours and die the moment the passphrase is rotated, because a grant names a fingerprint of the stored hash rather than the document. The stored value is a bcrypt hash, excluded from version history so it does not outlive rotation. The headless unlock endpoint answers identically for a wrong passphrase and for an unlocked document, so it cannot enumerate what is locked. (The built-in site's does not need to: a plain GET there already shows a lock page or the document.)Audiences remain the real access-control axis, and the two compose by AND: a locked document in a gated audience needs both. Point operators at api.md before they use this for anything that would matter if it leaked.
1.0 verdict (decided, #1535): still accepted at 1.0. Nothing here is a bug to fix. A shared passphrase is weak by construction, and the item says so. The bounds it lists still hold on main: the
:unlockbucket is 10/min per address (lib/kiln_cms_web/rate_limit.ex:53), and grants live 12 hours (lib/kiln_cms/cms/content_password.ex:54) and name a fingerprint of the bcrypt hash, so rotating the passphrase kills them. The 1.0 obligation is documentation. The pointer toapi.mdalready meets it: audiences are the access-control axis.Unknown
Hostheaders resolve to the default organization — on a single-org deployment. #563 added the control; since #1547 an unsetTENANT_STRICT_HOSTturns it on by itself once a second organization exists, on every node and with no restart, so a multi-tenant deployment is no longer exposed by default. Since #1662 an explicitTENANT_STRICT_HOST=falseno longer switches it off there either: once a second organization exists, unknown hosts are refused whatever the setting says, and Kiln logs an error — at boot, when the second org is created, and on/editor/system— that the setting is being ignored. There is no escape hatch; a host a multi-org deployment should answer on can be given to an organization or redirected to one at the proxy. With the control on, an unresolvableHostis refused with a bare 404 rather than served the default org, across everything the router serves plus LiveView mounts and the GraphQL and visual-editing sockets. What remains:- A node that misses the create's
Phoenix.PubSubbroadcast (partitioned, or mid-boot) stays lenient until its periodic recount, at most 30 seconds later (#1654; it was five minutes). The recount is onecount(*)onorganizationsper node every 30 seconds, and stops once the node has seen a second organization./editor/systemshows the window while it is open. - If the organizations cannot be counted at all (boot with Postgres down),
an unset setting fails closed: unknown hosts are refused until a
count succeeds. An explicit
falsestays lenient in that state — #1662 overrides it only on a count that actually found a second organization.
Terminating unknown hosts at the proxy is still worth doing as well.
A host whose lookup could not run — Postgres down — is refused too, since falling back would reopen exactly this leak on an unrecognized host, but with a
503rather than a404(#341): the host may well exist, and the two answers must not be conflated. With the control off a failed lookup takes the same default-org fallback an unmatched host does, so an outage refuses nothing that a working database would have served.Surfaces outside the control, none of which reads the ambient tenant:
Plug.Static(both mounts, including/uploadsunder the local storage adapter — UUID-keyed assets answer on any host, as they would behind a CDN); the health probes and the payment-provider webhook, both deliberately exempt so the control cannot fail a deployment's own liveness check or silently drop billing events./ws/collabwas a fourth until #655 wired it throughTenant.fetch_org/1like the other two sockets.A connected LiveView mount was outside it in a different way until #654, and strict matching would not have closed it: the host was known, just not the caller's.
socket.host_uriis rebuilt from the client's join payload rather than from aHostheader, andcheck_originadmits every subdomain of the base host, so a client served one org's page could join naming another's and take its:current_org— the assign editor LiveViews pass as thetenant:on authoring writes./livenow carriesconnect_info: [:uri]like the other three sockets and resolves from the host it connected on, refusing a claim that names a different org. Per-org authorization (Scoping.effective_tier/2, fail-closed on a foreign org) is still what authorizes the actions; this makes the assign mean what its callers assume.Two things about the refusal itself (#659). It is halted above the router and so above every rate limiter, which left one uncached organization lookup per request; unresolvable hosts are now cached as misses (in a cache of their own, so a flood cannot evict published content) rather than bounded by a per-IP budget, which could not tell a flood from a legitimate request behind the same NAT and so would have refused hosts that exist. And its plain-text body is distinguishable from the branded HTML 404 a known host gets for an unmatched path, so a dictionary sweep enumerates configured org slugs and custom domains. That second one is accepted rather than closed — the alternatives are showing unknown hosts the branded page (the default-org leak this control exists to prevent) or degrading every tenant's real 404, to hide names already public in DNS and TLS certificates. Terminate unknown hosts at the proxy if your tenant list is confidential.
That caching fix applies uniformly to all five callers that resolve a tenant (
SetTenant, the LiveView on_mount hook, and the three sockets — all route throughTenant.fetch_org/1orfetch_org_from_connect_info/1), so a repeated refusal is cheap everywhere, not just at the plug. What remained until #678 was visibility: a flood of distinct invented hosts still costs a lookup each, on every one of the five, and none of it reached an operator.KilnCMSWeb.TenantRefusalAlertnow fires one aggregated, cooldown-limitedLogger.warning+ Sentry alert per surface — tagged:plug/:live/:gql/:bridge/:collabso an alert says which surface is being flooded — the first time each is refused in a 15-minute window. It is deliberately not wired intoTenant.fetch_org/1itself (that would also fire for host-agnostic traffic on its way to being served, and for the LiveView on_mount's foreign-claim check, which is driven by the client's claimed host rather than one that failed to resolve) — each caller alerts from its own refusal decision instead.The issue also floated rate-limiting
/live/longpollspecifically at the router, alongside the plug's pipeline limiters. That turned out not to be available:use Phoenix.Endpointinstallssocket_dispatchas the first plug in the endpoint unconditionally, ahead ofSetTenant, the session plug and the router itself, regardless of where asocket "/path", …declaration sits in the module — so every one of/live,/ws/gql,/ws/collaband/ws/bridgeis dispatched and its connection accepted or refused before any router pipeline would run, on every transport including longpoll. There is no router-reachable place to put a limiter in front of them. Accepted rather than closed: the per-request cost a longpoll flood adds beyond the alerting above is one cached-miss lookup and one LiveView process spun up and torn down per request, the same bound #677 already put on every other caller — real but small, and an operator who sees the new alert firing can add a proxy-level limiter on/liveand/ws/gqlthe same way one is already recommended for unknown hosts generally, above.The quieter half is closed unconditionally:
Tenant.current_org_id/1now raises when the:current_orgassign is missing rather than reading the default org, so a forgottenSetTenantplug or:assign_current_orgon_mount fails loudly in test instead of serving the wrong tenant in production.1.0 verdict (decided, #1547): fixed. Roadmap decision 4 (2026-09-18) settled this, and #1547 implements it: an unset
TENANT_STRICT_HOSTturns on once a second organization exists. The 2026-09-27 audit closed the two gaps that were left: an explicitfalseis no longer honoured on a multi-org deployment (#1662, a### Breakingchange in 0.12.0), and a node that missed the broadcast catches up within 30 seconds rather than five minutes (#1654). The sub-residuals stay accepted: the plain-text refusal lets a sweep enumerate org slugs, and nothing router-reachable can meter/livelongpoll. Both are documented with a proxy-level remedy.- A node that misses the create's
The OpenAPI spec and Swagger explorer describe the write surface — closed (#567). Both were unauthenticated in every environment, production included, while GraphQL introspection was already disabled there for the same reconnaissance reason. They now follow
config :kiln_cms, :api_docs: on in dev and test, off in a production build, and back on withAPI_DOCS_ENABLED=truefor an operator publishing a public API. Disabled, both answer 404 rather than 403, so the instance is indistinguishable from one built without the surface. Residual: the gate is a plug on the:apipipeline that knows the two documentation paths, because the spec is served from inside theAshJsonApiRouterforward and has no route of its own to hang a pipeline on — so a future rename of either path has to be made inKilnCMSWeb.Plugs.ApiDocstoo. A test pins that the content routes it sits in front of are unaffected.1.0 verdict (decided, #1535): still accepted at 1.0. The residual is a maintenance hazard, not an exposure, and it is pinned.
KilnCMSWeb.Plugs.ApiDocshard-codes both paths (lib/kiln_cms_web/plugs/api_docs.ex:78-79), andtest/kiln_cms_web/api_docs_test.exsandapi_explorer_routes_test.exscover the gate and the routes behind it. The spec is also committed asdocs/api/openapi.json, so a disabled explorer hides nothing an attacker could not read in the repository.Rate limiting keys on
remote_ip. Behind a proxy withTRUSTED_PROXIESunset, every request shares one bucket — which throttles all clients together and makes per-IP limits meaningless. SetTRUSTED_PROXIES. No longer silent (#564): the app logs a warning, once per node, the first time a request arrives carrying a forwarding header (Forwarded,X-Forwarded-For,X-Client-IPorX-Real-IP) while no proxies are trusted. The trap itself remains — honouring the header without a trusted-proxy list would be worse, since it is spoofable — so this is detection, not a fix. On Fly.io, Railway and DigitalOcean App Platform (#1548), whose proxies publish no address range,CLIENT_IP_HEADERreads the proxy's own client-address header instead. It is honoured only while that platform's runtime variables are present, so a copy elsewhere is refused and logged. Two gaps remain: another app on the platform's private network reaches Kiln without the proxy and can send the header itself, and sockets see onlyx-headers, so the/sign-inform on Fly and DigitalOcean stays per deployment (seedocs/deploy-platforms.md).Two things about
:authspecifically, both worse for addresses many people share (an office NAT, or any deployment in the trap above). One successful browser sign-in now spends three of its forty: the page GET, the submit (#715), and the token-exchange GET the LiveView redirects to on success. A failed guess spends one, so the budget bites a legitimate user harder than the attacker it is aimed at — roughly thirteen sign-ins per minute per address (:authis 40/min since #747,lib/kiln_cms_web/rate_limit.ex). And the refusal reads as a wrong password, deliberately: it is the same genericAuthenticationFaileda bad credential produces, with no 429 and noRetry-After, because distinguishing it would tell an attacker exactly when their window rolls. The cost is that a throttled user is told the wrong thing. Both are accepted rather than closed; a per-address:authlimit that is generous enough never to inconvenience a shared egress is not a limit.TRUSTED_PROXIESis what makes the buckets per-client and is the real remedy on any deployment behind a proxy.1.0 verdict (decided, #1535): still accepted at 1.0 (correct the arithmetic). Detection is in
KilnCMSWeb.Plugs.ClientIp(lib/kiln_cms_web/plugs/client_ip.ex:226-232, a once-per-node warning that names the fix). Honouring forwarding headers without a trusted list would be the worse bug. The arithmetic had drifted: #747 doubled:authto 40/min (lib/kiln_cms_web/rate_limit.ex:28), so a successful browser sign-in spends three of forty, not three of twenty — about thirteen sign-ins per minute per address, not six. The paragraph above now says so (#1614). The trade itself stays.Preview tokens bypass authorization — the signed token is the grant. A preview link reads its one record with
authorize?: false: the caller is anonymous, and thePhoenix.TokensignaturePreviewToken.verify/1checks is the whole authorization. It is not tenant-less (#1309): the token carries the minting org'sorg_id, every redeemer refuses it on any other org's host and reads with thattenant:, and a token that names no org is refused outright. Validity and expiry are the control; what remains is that an issued link cannot be revoked before it lapses.1.0 verdict (decided, #1535): still accepted at 1.0 (rewrite the item). The item is out of date. #1309 closed the tenant half. Every redeemer pins the token's
org_idto the serving org and then reads withtenant: org_id:PreviewController(lib/kiln_cms_web/controllers/preview_controller.ex:23,45),TokenPreviewLive(lib/kiln_cms_web/live/token_preview_live.ex:33), the visual-editing read (lib/kiln_cms_web/controllers/visual_editing_controller.ex:117) andBridgeSocket(lib/kiln_cms_web/channels/bridge_socket.ex:250).PreviewToken.verify/1also refuses an older token that names no org (lib/kiln_cms/cms/preview_token.ex:159-168). What is left is the design itself. The read usesauthorize?: falsebecause the signed token is the grant. The token is bound to one record, lives 15 minutes (preview_token.ex:42) and is metered by:previewat 30/min. It cannot be revoked short of rotatingSECRET_KEY_BASE. That is an acceptable 1.0 shape for a short-lived bearer link; the item's first paragraph was rewritten to say so instead of "no tenant" (#1614).Four resources are world-readable by policy. Closed in #565.
Firing.PublishedArtifact,Firing.ReferenceEdge,CMS.FormFieldandSearch.BlockEmbeddingno longer declareauthorize_if always()on reads:PublishedArtifact— the one that mattered, because it holds rendered bodies and #337 Phase 2 made gated content paid rather than merely restricted. Its read now runsFiring.Checks.DocumentReadable, which re-reads the source document under the caller's own authorization, so the audience axis holds at the artifact tier too. It was never exploitable over HTTP (every path resolved the record through the audience-gatedFiring.Delivery.published/4first); what is closed is the future internal caller that would have read one without that resolution.ReferenceEdge,BlockEmbedding— enumeration surfaces (the link graph including draft sources;ancestor_contextblock text from every indexed document), now editor-and-up.FormField— reads now filter onform.active == true, mirroring the parentForm's visibility instead of relying on it being enforced elsewhere.
Delivery, the re-fire wave, the indexer and form rendering were unaffected because they read as the system (
authorize?: false). Seepolicy-matrix.mdfor the resulting grants.1.0 verdict (decided, #1535): nothing to decide (closed). Closed in #565 and re-checked on main.
PublishedArtifactreads throughFiring.Checks.DocumentReadable(lib/kiln_cms/firing/published_artifact.ex:103), and the onlyauthorize_if always()left among the four resources isFormField's org-admin bypass. It keeps its place in the list because later items are cited by number.Unauthenticated GraphQL runs with
actor: nilandtenant: nil. Closed in #1614 — the premise had been stale for a while; #1614 is the proof. An anonymous query runs withactor: nil, so the read policies decide what is visible (published, public-audience rows only), but it does not run withtenant: nil.KilnCMSWeb.Plugs.SetTenantresolves the org from theHostin the endpoint (lib/kiln_cms_web/endpoint.ex:187) and sets it as the Ash tenant on every HTTP request (lib/kiln_cms_web/plugs/set_tenant.ex:223);AshGraphql.Plugcopies it into the Absinthe context for/gql(lib/kiln_cms_web/router.ex:78), and the hand-writtenmenuandcontentAsOfresolvers read it from there./ws/gqlresolves its own from the connect URI (lib/kiln_cms_web/graphql_socket.ex:46), and:strict_tenancy(config/config.exs:483) makes a tenant-less read fail closed rather than span orgs.test/kiln_cms_web/graphql_cross_org_test.exspins it over HTTP. With two orgs publishing under the same slugs, every anonymous-readable root field — thepublished*lists, the*BySlugand*Translationslookups, a lookup by id throughfilter, thesearch*andautocomplete*fields,categories/tags/tagGroupsand their*BySlug,menuandcontentAsOf— finds each org's content on its own host (the positive control) and none of org A's on org B's host; a shared slug on B's host answers with B's record. The anonymous GET cache (#1571) cannot carry an answer across: A's ETag presented on B's host gets B's full body and B's surrogate keys, never a304. And aHostthat names no org is refused with a404once a second org exists (#1547), or served the default org's site — none of A's — where strict matching is forced off (residual 3). Mutation-checked: dropping the tenantSetTenanthands to Ash fails 28 of the 31 root-field cases on B's host — 23 show org A's content, and the five single lookups on a slug both orgs use find two rows and error — and making themenuandcontentAsOfresolvers ignore the context's tenant fails their positive controls. ThesemanticSearch*fields are not covered: they need the opt-in ML build, and read through the same tenant-scoped actions.1.0 verdict (decided, #1535 → #1614): fix before 1.0 — done (closed). No code changed; the tests are the fix. It keeps its place in the list because later items are cited by number.
A block field policy could be cleared by omission — closed for the reported case (#566).
EnforceBlockFieldPolicystopped an editor setting an admin-only block field, but a headless client that submitted a block tree without ids and omitted the field got the declared default, silently clearing an admin-set value. A wholly id-less tree that omits such a field is now refused when any stored block of that type holds a non-default value for it, with a message naming the remedy (send the ids).The rule only ever refuses: it never permits a write that used to fail and never writes a value nobody submitted. Both alternatives considered were worse — pairing id-less blocks by position looks like identity and is not (it hands the featured slot to whatever new content lands there, and refuses an editor merely inserting a block above a featured one), and carrying the value forward silently writes something the client never sent.
Nested
columnschildren are covered too (#774): the whole tree's multiset of role-restricted non-default nested values must be identical before and after, so a non-admin can neither introduce one nor drop one by omission, but may resubmit a column holding an admin-set value unchanged — which the old per-child default rule refused outright.That comparison is only sound if it sees a value exactly where a reader would. It used to search the submitted term for maps carrying a
"blocks"key, which is a guess about where children live, and a guess is defeatable:{:array, :map}fields carry no schema, so a whole child list could be parked under any key —%{"blocks" => [real], "trash" => [%{"blocks" => [parked]}]}, or insidegallery.imageswith nocolumnsblock present at all — and the parked copy was counted while nothing rendered it, offsetting the removal of a real one. The traversal now asks the block for its children (Columns.child_maps/1), so it reads the same positions the renderer does (#956). A block type that nests children and does not declare them is invisible to this check, which is the cost of mirroring rather than guessing — and strictly better than a guess that failed open silently, for content no reader ever sees.A multiset preserves the count of admin-set values, not their binding, so on its own it allowed a re-target — clearing the value on one child and setting it on another of the same type in one write. That is narrowed, not closed (#865).
The content editor stamps each nested child an
"id"for its own bookkeeping, and the key survives into storage. Where those ids exist the check binds each admin-set value to the child holding it: a child returning under a known id must return with that id's value, and a child that held a restricted non-default value must return under the same id still holding it. Separately and unconditionally, an id that names two children in one submission is refused — without that the two collapse when indexed, the last wins, and a decoy sharing the real child's id satisfies the binding while the rendered content quietly loses the value.The binding is required, not gated (#954). It used to apply only when the client demonstrably round-tripped ids, because ids were unreadable —
blocksis notpublic?and GraphQL carrieshide_inputs: [:blocks], so demanding an id back named a remedy most callers could not perform, and a caller willing to drop every id was quietly downgraded to the count-only multiset, where the re-target survived. Every caller can now read them: the publicblock_idscalculation projects the tree to_id/_typeonly — nested children in the positions they render, no field values, so the non-public?blocksboundary is untouched — on any policy-scoped read. Drafts are therefore editor-scoped by the row read policy; on published rows the fired:jsonartifact already names each block's id_id, nested children included, and the write path accepts that spelling (#990). An id-less submission against an identified stored tree is refused with the surface named in the error.Two deliberate carve-outs, both keyed on what is stored rather than on who is asking: a
restore_versionfold that carries no ids is exempt (the tree is our own history, vetted by this same policy when written, and versions captured before the editor stamped children fold back id-less by construction — no surface can ever hand those ids back; a restore that does carry ids keeps the binding like any other write). And a stored tree whose children carry no ids binds nothing, so it is governed by the multiset exactly as before rather than bricked by a demand for ids never stored.Residual: that second carve-out — a restricted value stored on an id-less child (a page authored by an id-less headless admin client; the editor always stamps) can still be re-targeted until an id-stamping save gives the document identity, and an id-less
restore_versionreplays whichever vetted placement history holds. Both are bounded by the multiset: the count can never change.Residual, all about which block an id names rather than what a field may hold: an editor can still reuse the id of another block of the same type to move an admin-set field off the block that had it, and an empty
block_treedeletes the block outright. Both predate this and need the write path to verify a submitted id belongs to the block it claims. The same is true of nested children: ids there are client-supplied, so relabelling which child an id names is believed, and only the two-children-one-id case is decidable without an ownership check.1.0 verdict (decided, #1535): fix after 1.0. Verified unchanged on main (
lib/kiln_cms/cms/changes/enforce_block_field_policy.ex:140-153). What remains needs an editor who may already write that document in that org. Such an editor can move or drop an admin-set block field by relabelling ids, or re-target a value stored on an id-less child. The multiset bounds the count, and nothing crosses a tenant or an audience, so this is integrity within an editor's own grant, not confidentiality. Closing it needs a new primitive: server-verified block identity on the write path. That is a design change to the block tree, too large for the contract-freeze window, and better done after the legacy block shape is retired (#1537).Per-account throttling is per node, in memory, and keyed on attacker-chosen strings. Closed in #1619.
AccountThrottle(#478) used to hold its budgets in ETS, asKilnCMSWeb.RateLimitheld:auth, so a restart forgave every accumulated attempt and N nodes gave an attacker N budgets. EveryAccountThrottlebudget (sign-in, second factor, the mail and alert budgets) and the credential buckets ofKilnCMSWeb.RateLimit(:auth,:register,:unlock) now count in one Postgres table,throttle_counters, throughKilnCMS.Accounts.ThrottleStore. The item's own constraints still hold:
- Nothing on the user row. A counter row is keyed on a SHA-256 of the budget's key and names no account, so a guess is not a write to a row the attacker chooses, and an address with no account counts exactly like one with an account.
- Bounded and expiring. Whatever was submitted, a key is stored as 32
bytes. A row lives for its window (one minute to six hours) plus at most
five minutes, when an Oban cron job deletes closed windows in bounded
batches. The table therefore holds about (distinct keys charged per
window) × (windows alive). Each sign-in attempt also costs the server a
bcrypt verification (a refusal burns a simulated one), so sign-in rows
appear no faster than the cluster can hash. Mail-budget rows are bounded
per source address by the
:authbucket in front of them. A row costs about 160 bytes with its indexes (measured: 100,000 rows in 16 MB), so a million live keys is roughly 160 MB, and each one is gone within its window. - Atomic. One
INSERT … ON CONFLICT DO UPDATE … RETURNING countper charge, so a simultaneous burst from every node admits exactly the budget (test/kiln_cms/accounts/throttle_store_test.exsproves it with two:peernodes). Windows are aligned to the database clock, so node clock skew does not split a window across rows.
Cost, measured (Postgres 17 on Apple silicon, local socket): one upsert is
p50 0.34 ms / p99 1.1 ms sequentially, and p50 1.1 ms / p99 5.4 ms with
sixteen concurrent writers on a single key (the one-account attack shape),
against 208 ms for the bcrypt verification the same attempt already pays.
An UNLOGGED table measured p99 1.45 ms on that hot key and was not
adopted: it is truncated after a Postgres crash and empty after a failover,
which would bring back the "restart forgives everything" half of this item.
A cluster-replicated in-memory counter (:pg/PubSub, or a Hammer backend)
was rejected because a partition would still grant each side a full budget,
and a full-cluster restart would still forgive everything. A local fast path
with a shared check near the limit was rejected because the measured cost
left nothing to optimise. Redis and Dragonfly stay out, per D1/D2.
Fail direction. When the database cannot answer (the table missing during
a rolling deploy, a lock or pool timeout), a budget is counted on the node
in ETS instead. That is the pre-#1619 bound, never a weaker one. Failing
open would allow unbounded guessing while only the counter is broken.
Failing closed would let anyone who can slow one query lock every account
out of sign-in. The fallback is logged at :error, at most once a minute
per node.
What remains: an attacker who spends a victim's mail budget still delays that victim's own reset mail until the window rolls, and every such suppression is still logged. That residual is unchanged, and it now holds cluster-wide rather than per node.
**1.0 verdict (decided, #1535 → #1619): fix before 1.0. Fixed in #1619.**
`AccountThrottle` and the credential `KilnCMSWeb.RateLimit` buckets now
charge `KilnCMS.Accounts.ThrottleStore`
(`lib/kiln_cms/accounts/throttle_store.ex`). The flood-ceiling buckets
(`:api`, `:delivery`, `:gql`, …) stay per node on purpose: over-admitting a
flood ceiling by the node count costs capacity, not a secret.The
:browserpipeline is not rate-limited, so/,/developers, all/editor/**LiveView mounts, and the account/governance export endpoints are unthrottled. They are session-gated (except the first two), so this is an availability rather than a confidentiality concern.The
/livesocket is unthrottled in the same way, and more broadly: it has no limiter on joins or on events at all. The one thing that used to make that a confidentiality concern is closed — the sign-in submit is a LiveView event, and #715 charges it the:authbucket on the action rather than at a plug it never passes, so brute force over the socket is bounded per address exactly as the HTTP form is. What remains is volume: joins are uncounted, so a caller replaying a scraped session token pays nothing per attempt. A malformed join — one whose"url"is present but not a binary — is worse than uncounted: it function-clauses before any mount hook, ahead of the channel'stry/rescue, so it is a crash rather than the clean 404 a url-less probe gets. #700 stops that reaching the error tracker (KilnCMS.SentryFilterdrops exactly that one function'sFunctionClauseErrorat one arity, leaving the local report intact). Sizing it honestly: Sentry.Dedupe already collapsed the flood to roughly one event, so what this removes is a caller's ability to plant a real-looking issue at will, not a quota burn. The join itself is still free, and the Sentry logger handler's own:rate_limitingoption — which would bound any crash shape rather than one named function — is available and unset. Narrower than it sounds: #678 closed the tenant-refusal half of "/liveis unthrottled" — a flood of unresolvable hosts now alerts (see item 3 above) — but a flood of valid, successfully-resolving joins is still free and still uncounted. That's this item's actual gap, and it remains open; tracked in #1183. Narrowed by #1183:/liveroot joins are now charged per client address (KilnCMSWeb.LiveJoinBudget, the:live_joinbucket inKilnCMSWeb.RateLimit, 300/minute by default). It is anon_mounthook on every Kiln LiveView module — ahead ofLiveRouteGuard, so a url-less join is charged before it is refused — and only a connected root mount pays: the dead render is an HTTP request, a nested child was covered by its parent's join, and patch/navigate inside alive_sessiondoes not remount. Over budget, the join raises a 429 beforemount/3, which the channel turns into areloadreply and a stopped process (no mount, no render); the JS client backs off with jitter. Narrowed by #1305: frames on an established/ws/collabconnection are now charged, per account rather than per address or per connection (KilnCMSWeb.SocketEventBudget, the:collab_eventbucket, 6,000/minute per actor by default). Everyhandle_in/3— update, awareness, and the ignored rest — and thejoin/3that opened the channel spend the same budget, keyed on the actor the socket authenticated as, so a reconnect, a second tab or a second room does not mint a fresh budget and a flooder is bounded per credential; a join over budget is refused before its authorization reads. Per account because legitimate collaboration is a high-frequency stream and one office NAT holds many editors (address), and because a fresh websocket is the cheapest thing a flooder has and the honest recovery from a refusal is itself a reconnect (connection). Over it, the connection is closed — not the channel: a{:shutdown, _}stop isphx_close, whichphoenix.jstreats as a finished leave and never rejoins — so the client reconnects on a backoff, its rejoins are refused until the window turns, and on the join that succeeds it pushes back the local ops the room is missing (a Yjs state-vector diff inassets/js/collab.js), so nothing typed meanwhile is lost. Two things on the client are part of the control: awareness pushes are coalesced to ~10/s (without that a mouse-drag selection emitted at the browser's event rate and one long drag could reach the ceiling alone), and an"over budget"join refusal is treated as transient rather than seeding the document as the first peer.awareness_request— the one frame that makes every peer send a frame — is relayed at most once per ten seconds per channel, so one seat cannot spend the room's budgets through it. What this bounds is the per-frame work: theDocServerapply and room fan-out per update, and the full re-authorization (three DB reads) everySocketReauth.update_floor/0of them.Narrowed further, the
/ws/*half:/ws/gql,/ws/bridgeand/ws/collabconnects are now charged the same way, per client address, each against its own bucket (KilnCMSWeb.SocketJoinBudget;:gql_join,:bridge_join,:collab_joininKilnCMSWeb.RateLimit, 300/minute by default each — three buckets rather than one shared, so a flood against/ws/gql, the one of the three anonymous by default, cannot spend a budget a signed-in editor's/ws/collabsession then pays for from the same office NAT). Charged first in each socket's ownconnect/1,2,3, ahead of tenant/token/auth resolution, the same orderingLiveJoinBudgetuses and for the same reason: a connect this deployment is about to refuse anyway still cost a handshake. A refused connect returns:error— there is no 4xx-during-mount shape to raise here, so the client's own reconnect logic backs off and retries, the same as any other refused socket connect.Narrowed further,
/ws/gqldocuments: each document a client sends over an open/ws/gqlconnection (a query, a mutation or a subscription) is now charged to:gql, the bucket/gqlrequests are charged to, under the address the connect was charged under (KilnCMSWeb.GraphqlLimits.SocketDocumentBudget, the first phase of the socket's document pipeline). One bucket for both transports, so moving from/gqlto the socket gains a client nothing. Per address, not per actor as/ws/collabframes are: documents are not a per-keystroke stream, the per-address size/gqlalready has fits them, and an anonymous socket, where the gap was, has no actor. A subscription's pushes are not charged. They re-run the phases Absinthe.Phase.Init recorded when the client subscribed, and the budget runs before Init. Over budget, the document is answered with a GraphQL error (too_many_requests, withretry_afterin seconds) before it is parsed, and the connection and its subscriptions stay up. Each document is also held to the complexity, depth and token limits/gqlhas (KilnCMSWeb.GraphqlLimits).Still uncounted, and still this item's remaining gap: events on
/live(no lifecycle hook runs before everyhandle_event/3; the sign-in submit stays the one charged case, #715)./ws/gql'sunsubscribeframes are not charged either; each removes one registry entry and runs no document./ws/collabframes and/ws/gqldocuments are the event surfaces counted so far (above);/liveevents remain the harder problem #1305 described (no single choke point, no obvious per-event cost model).1.0 verdict (decided, #1535): fix after 1.0. Every path this item called a confidentiality concern is now closed or metered.
KilnCMSWeb.LiveJoinBudgetmeters/liveroot joins,KilnCMSWeb.SocketJoinBudgetmeters/ws/*connects,SocketEventBudgetmeters/ws/collabframes,GraphqlLimits.SocketDocumentBudgetmeters/ws/gqldocuments, and the sign-in submit is charged (#715). What is left is volume:/liveevents after a join, and the unmetered:browserpipeline (lib/kiln_cms_web/router.ex:81-90). Both sit behind a session for everything except/and/developers. One correction for whoever takes this on: the item says no lifecycle hook runs before everyhandle_event/3. In factattach_hook(socket, name, :handle_event, fun)does exactly that for a LiveView's own events, and the codebase already uses it (lib/kiln_cms_web/nav_preset.ex:30). Only LiveComponent events bypass it, so a per-actor event budget is feasible, just not free. A cheap companion could land at any time: the Sentry logger handler's:rate_limitingoption is still unset (lib/kiln_cms/application.ex:333-335).Periodic CSP re-review as the editor adds third-party assets. Closed by #1615 (the 1.0 review, from #1535's verdict): the reviewed policy is recorded here, and a change to it is a change to this item. The console and public site are served, per request, with:
Directive 1.0 value Why script-src'self' 'nonce-…'Per-request nonce ( put_browser_csp). A site's code injection adds its listed origins and the'sha256-…'of its inline snippets, on delivery pages only.default-src'self'connect-src'self'Was 'self' ws: wss:. Every socket Kiln's pages open (/liveand its longpoll,/ws/collab,/ws/gql) is same-origin, and CSP3 matches'self'tows:/wss:on the page's own host and port. No Kiln browser code connects cross-origin: uploads ride the LiveView channel, the presigned-upload API is for API clients, oEmbed and Unsplash resolve server-side, andbridge.jsruns on the external front end under its own policy. Code injection adds a site's listed origins,wss://included (only this directive accepts one).style-src'self' 'unsafe-inline'Kept. Templates and blocks use inline style=attributes (focal points, column widths, chart bars), which cannot carry a nonce, and the brand tokens and custom CSS are<style>elements. Without script, injected CSS can restyle a page but can only fetch from the originsimg-srcandfont-srcalready trust.img-src'self' data: blob:+ runtimeKept. Widened by CSP_IMG_SRC(operator), Unsplash's thumbnail host while that integration is on, the enabled oEmbed providers' fixed thumbnail CDNs (#489), the site's own storage-profile origins (Plugs.SiteStorageCsp, #1559), and code injection's image list.media-src'self' blob:+ runtimeKept. CSP_IMG_SRC(#494) and the site's own storage origins; not the thumbnail hosts.font-src'self' data:frame-src'self'YouTube, Vimeo playersobject-src/base-uri'none'/'self'frame-ancestors/form-action'self'/'self'Form embeds serve their own policy ( KilnCMSWeb.Embed).test/kiln_cms_web/csp_connect_src_test.exsasserts the servedconnect-srcexactly, ande2e/tests/csp.spec.jsloads the console, an editor, the media library, a public page and the same page on a second host name with no CSP violation, then shows a socket to another origin is refused. Residual:CSP_IMG_SRCis operator input split on whitespace and trusted as written; everything a tenant supplies passesKilnCMS.CMS.Validations.CspOriginsorSiteProfiles.csp_origins/1, which admit only scheme, host and port.Secrets rotation runbook (DB URL,
SECRET_KEY_BASE,TOKEN_SIGNING_SECRET, S3 keys) is not written down. Closed by #1304:secrets-rotation.mdis the per-secret procedure, verified against what the code does rather than what would be reasonable. RotatingSECRET_KEY_BASEpermanently orphans the vault-encrypted columns, and the ActivityPub actor key cannot be re-keyed. Closed by #1487:KilnCMS.Keys.Vaultreads underPREVIOUS_SECRET_KEY_BASEas well while a rotation is under way, andmix kiln.vault.reencrypt(KilnCMS.Release.reencrypt_vault/1in a release) moves every vault column to the new secret. It finds those columns by type, never overwrites a value it cannot open, and is safe to run twice.SiteFederation's admin-only:rekeyreplaces the actor's keypair under the same actor id and sends followers a signed actorUpdate. Residual, and the reason to read the runbook before an incident rather than during one:- Sessions and tokens are still hard cutovers.
TOKEN_SIGNING_SECRETandSECRET_KEY_BASEeach sign every user out. The read window covers the vault only:Plug.SessionandAshAuthentication.Jwteach derive one key from one secret. - The order of steps decides whether data survives. If the old value is
retired before the task has run, the vault columns are orphaned exactly as
before. The only signal is the task's
unreadablecount, a warning in the log and the federation panel. - Re-encryption is not revocation. Backups taken before the task, and any other copy of the database, still open with the old secret. After a leak, the underlying secrets have to be rotated as well: the DKIM key, billing and social credentials, and the actor key.
- A re-keyed actor depends on its peers. Servers that ignore actor
Updates keep the old key until they re-fetch the actor, and until then the old key still signs traffic they accept.
Pairs with
backups.md, where the sameSECRET_KEY_BASEis part of the backup.1.0 verdict (decided, #1535): still accepted at 1.0. The two gaps this item was opened for are closed: the runbook (#1304), and the vault read window with
mix kiln.vault.reencryptand the actor:rekey(#1487). The four remaining bullets are properties of the mechanisms, and no change in Kiln removes them. Sessions and JWTs are each signed with one secret, backups outlive a re-encrypt, and peers cache an actor key. Rotating without signing everyone out would need dual-key session verification. That is a feature for after 1.0, not a fix.- Sessions and tokens are still hard cutovers.
The collaborative-editing socket is scoped by topic, not by tenancy. Closed by #655. The socket token still names only a user, so it establishes who and nothing more;
CollabChannel.join/3now resolves the topic to a real document, loads it as that user under the connection's org, and authorizes the:autosavewrite — not the read, which is the wider scope and would have let a reader author, since the checkpoint persists withauthorize?: false. The socket resolves its tenant from the connect URI, so it is insideTENANT_STRICT_HOSTlike the other two; the doc key is rebuilt from the resolved record rather than the client's topic string; andCollab.Crdt.Checkpointwrites back under the document's own org rather thandefault_org_id/0. Every refusal reports the same "not found", so the channel answers no question a caller could not already answer over HTTP.Authorization runs at connect and join and is not revisited — closed for the deliberate cases (#675). An account that was demoted, removed from an org, had its scopes or audiences narrowed, or was erased used to keep everything its live sockets already held, for as long as the tab stayed open, while every HTTP surface refused it immediately.
KilnCMS.Accounts.SessionEvictionnow drops those sockets from the actions that make the change, and all four surfaces can actually be dropped:GraphqlSocket.id/1returnednil(Phoenix for "never disconnectable"),BridgeSocketis a raw transport with noid/1callback and subscribes itself, and nothing set alive_socket_id, so/livewas undroppable too. Evicting is not re-authorizing: the client reconnects and runs the full check again, which is the cheapest correct answer and costs nothing on the CRDT hot path.Periodic re-authorization is the backstop — closed by #775. Eviction is prompt but not complete: it fires on the actions wired to it, so an authorization change nobody remembered to wire in — a new action that narrows a grant, a role-resource edit, a direct
Ash.updatefrom a migration or a mix task — was invisible to a live socket, and so was a change to the document rather than to the user.CollabChannelnow re-runsauthorize/3— the same functionjoin/3runs, not a second spelling of it — against a reloaded actor, on a timer and after every 200 inbound updates, and closes the connection when it no longer passes (KilnCMSWeb.SocketReauth.close_connection/1, the same"disconnect"broadcast eviction uses), then stops the channel. The order matters and is not a detail: a channel that merely stops with{:shutdown, _}is aphx_closeframe, whichphoenix.jstreats as a finished leave and never rejoins — the room would stay dead in that tab until a reload, buffering edits into a channel that would never send them, even after the grant came back. A closed socket is what the client recovers from: it reconnects on a backoff and rejoins, and every rejoin runs the fulljoin/3, refused while the grant is narrowed and admitted once it is restored.BridgeSocketdoes the same for its read on the same timer (no update count: it accepts no writes; and as a raw transport its stop is the socket closing). The reload is the mechanism: re-running the policies against the actor struct the socket connected with would answer from the same stale role, scopes and audiences forever.The exposure window an operator can rely on is 30 seconds — the interval in
KilnCMSWeb.SocketReauth— plus the in-flight message. Both ends of the bound are load-bearing: the timer covers a room that is connected but idle, and the 200-update floor covers one that is busy, since at typing speed a timer alone bounds the time a revoked editor keeps writing but not the number of writes. The floor only binds above ~6.7 updates/second; below that the timer always fires first.The interval comes from a measurement, not a round number —
MIX_ENV=dev mix run priv/bench/socket_reauth.exs, on a local Postgres:actor reload 1 query 0.31–0.38 ms document read + Ash.can?(:autosave)2 queries 2.7–3.0 ms full check 3 queries 3.0–3.4 ms Crdt.apply_update/2— 0.002 ms Note what that says, because it is the opposite of the assumption the issue started from: the check is not cheap relative to the CRDT work. It is ~1,900x a single update apply, because Yjs runs in a NIF and the check is three database round trips. One room's CPU was never the constraint though. The binding cost is queries per second across the deployment, and the number to reason about is the open-document ceiling (
Collab.Crdt.max_documents/0, 500) at ~5 peers each: 2,500 channels checking every 30s is +250 queries/s, or ~0.27 connection-seconds per second — under 3% of the defaultPOOL_SIZEof 10 — at a ceiling no real deployment sits at. Ten seconds would be roughly three times that for a window an operator gains little from; sixty would halve it for a window they reason about as "a minute" and would have to round up. 30s is the value; it is overridable withconfig :kiln_cms, :socket_reauth_interval_ms(and:socket_reauth_update_floor), both validated so a bad value falls back to the default rather than silently disabling the backstop.This also makes the mechanism cluster-safe by construction, which eviction is not: each channel re-checks itself against the database from whichever node holds it, so it needs no message to cross a node boundary.
Eviction's cross-node reach is reasoned about, not exercised — a deliberate decision (#1060), not an oversight. The deployment genuinely is multi-node capable (
DNSClusteris in the supervision tree, andPhoenix.PubSubis started with no adapter override, so it defaults to the distributed one), andSessionEviction.evict/2is oneEndpoint.broadcast/3call — no custom fan-out to get wrong. What this codebase does not do, anywhere, is stand up a second BEAM node inside the test suite to prove aPhoenix.PubSubbroadcast crosses one:Cache.ClusterBust(#739), landed for the identical shape of problem — a local action that must reach every node — draws the same line. Its own suite proves the writing node's broadcast and a receiving node's handler independently, against the same topic, and stops there; it does not spin up a real second node either.SessionEvictionTestmirrors that scope: every trigger, every socket's droppability, and the broadcaster/listener topic agreement are all proven on one node, which is what our own code is responsible for —Phoenix.PubSub's distributed delivery is upstream, third-party behavior this app depends on rather than implements, the same way a test suite here does not re-verify Postgres's own transaction isolation.#743records the identical per-node assumption for the pending-sign-in cache, for the same reason. The 30-second backstop applies either way, and bounds the cost of trusting this.Residual: the re-check re-runs the join's rule, so what it catches is exactly what a fresh join would refuse — no more. A document publish under an open room used to make that concrete:
Ash.can?on:autosavedoes not consult the row-levelstate == :draftfilter that action carries, so publishing does not close the room, and collaborative prose no client autosave had captured was lost at checkpoint. That was a data-loss bug rather than an authorization one, and it is closed at the publish path rather than at the authorization check (#1061):Changes.CheckpointCollabRoomcarries the room's converged prose into the publish's own write, and the room is told afterwards so its editors stop typing into a document nothing will persist. The authorization re-check is unchanged — collaborative editing of published content remains supported.1.0 verdict (decided, #1535): still accepted at 1.0. Closed by #655, #675 and #775. The residual is that the re-check catches exactly what a fresh join would refuse, and that is the intended meaning: it is an authorization check. Its one data-loss consequence, publishing under an open room, was closed at the publish path (#1061). The bound an operator can rely on stays 30 seconds (
lib/kiln_cms_web/channels/socket_reauth.ex), and cross-node eviction stays reasoned, not exercised, as #1060 decided.Webhook deliveries have no anti-replay. Closed (0.12.0, #1616). Every delivery carries
x-kilncms-webhook-signature: t=<unix>,v1=<hex>, an HMAC of"<t>.<body>", and adelivery_idinside the signed body (echoed inx-kilncms-delivery-id) that stays the same across a delivery's retries. A receiver that rejects atoutside its window (five minutes is the documented default) and remembers the delivery ids it has seen inside that window cannot be replayed to. Re-stamping a captured request with a freshtdoes not verify, becausetis inside the MAC.Remainder. The original body-only
x-kilncms-signatureis still sent during a deprecation period. Removed in 0.12.0 (#1616). The body-only header, deprecated in 0.10.0, is no longer sent, so there is no signature left on a delivery that verifies without a timestamp. A receiver that verified only the old header now sees it missing and must move to the timestamped one (the 0.12.0 upgrade note). An admin redelivery is a new delivery with a new id and a fresh timestamp, on purpose. See webhooks.md.1.0 verdict (decided, #1535 → #1616): fix before 1.0 — done. The timestamped header is the only signature a delivery carries (
KilnCMS.Webhooks.timestamped_signature/3, sent byKilnCMS.Webhooks.DeliveryWorker), so the covered webhook contract at 1.0 has no freshness-free signature in it. What remains is the receiver's side of the bargain: a receiver that skips thetcheck, or verifies nothing, is replayable by construction, and no sender change can fix that.An org admin's code injection is same-origin with the console when
KILN_CONSOLE_HOSTis unset (#1661). Site code injection (head_html/footer_html, #490) runs on an org's delivery pages. By default the editor console answers on that same host, so an injected script canfetch("/editor/…", {credentials: "same-origin"})in the browser of any editor who opens the public site while signed in, and act with their session — including a platform admin, who is an admin on every org. On a single-org deployment the org admin and the operator are one party and this is the operator's own script. On a multi-org deployment it is one tenant's admin reaching other tenants' editors, and the operator's.The mitigation is
KILN_CONSOLE_HOST(#740): consoles are then served only on hosts no tenant controls, and delivery script is cross-origin to all of them. Since #1688 that covers every organization: the bare console host is the default org's console and<slug>.<console host>is each other org's, resolved from the host like a tenant subdomain, so no org's console shares an origin with any site or with another org's console. Cookies stay host-only (__Host-in production), so a session on one console host is never sent to a site host or to another console host. Passkeys keep their RP ID (thePHX_HOSThost) and are accepted on console origins under it, never on tenant site origins (KilnCMS.Accounts.WebAuthn.origin_allowed?/2). Why one host per org and not one shared, org-switching console host: decision record 0011.It stays opt-in, because a console host is a DNS and TLS change (the host and its wildcard) that Kiln cannot make for an operator on upgrade. So Kiln says so: once a second organization exists and
KILN_CONSOLE_HOSTis unset, it warns at boot (aKilnCMS.Config.Reportwarning, which reaches Sentry), when the second org is created, and on/editor/system(KilnCMSWeb.Tenant.console_shares_origin?/0). See code-injection.md.1.0 verdict (decided, #1661; structural gap closed, #1688): closed by configuration. With
KILN_CONSOLE_HOSTset, a multi-org deployment isolates every org's console. What remains is the unconfigured deployment, which is warned about. Its alternative is to treat "org admin" as equivalent to console access and staff it accordingly. One edge stays by design: the sign-in and account routes are shared (KilnCMSWeb.Surface), because members sign in on the site for gated content. So a session someone creates on a site host is same-origin with that org's code injection. It reaches the shared routes only, never a console route, and it is a separate session from the one on any console host.Queued mail carries sign-in links, sealed under
SECRET_KEY_BASE(#1843). Account confirmations, password resets, magic links and newsletter confirmations are queued on the:mailqueue with their rendered message, and a newsletter confirmation link never expires. Before 1.0 that message sat in the job's args in the clear, and a mailer that crashed had the whole email stored as the job's error, shown on Mail and Outgoing mail and sent to the log and Sentry. Now the adapter call is guarded, so a crash is recorded by name only (KilnCMS.Mail.MailerCrashError); no mail server at all holds the mail with a reason instead of crashing; the subject and body travel sealed withKilnCMS.Keys.Vault(KilnCMS.Mail.open_args/1) and are dropped once the job finishes (forget_body/1);KilnCMS.SentryFilterstrips mail bodies from every event; and the delivery panels show an error's first line only, with links and addresses removed (KilnCMS.Mail.display_error/1).What remains: anyone holding
SECRET_KEY_BASEand read access tooban_jobscan open a pending job's message, which is the same party that can already mint sessions. The recipient address stays in the clear, in the row and in Oban's job log lines, because the delivery panel needs its domain. And the upgrade's scrub (KilnCMS.Mail.Scrub) reaches only the database: links that an earlier release already wrote to logs or Sentry stay there until the operator deletes them, and the 1.0 upgrade notes tell them to treat those links as exposed.
Not on this list, but named by the 1.0 roadmap: /api/ask lets an anonymous
caller drive LLM cost (see Other outbound calls above). 1.0 verdict
(decided, #1535): still accepted at 1.0. Generation is off by default
(generator: nil, KilnCMS.Ask). When an operator turns it on, /api/ask has
its own KilnCMS.LLM.Budget buckets on top of the :api limiter. One is per
caller and falls back to the client address; the other is per org and is the
actual spend ceiling. Exhausting either degrades the answer to retrieval-only
instead of failing (lib/kiln_cms/ask.ex:60-69). An operator who enables
generation chooses the ceiling. This paragraph is deliberately not a numbered
item, so no reference elsewhere shifts.
Operating the dependency audit
mix deps.audit (mix_audit) checks
mix.lock against mirego's mirror of the Elixir security advisory database,
and mix hex.audit checks it against the advisories Hex itself serves. Both
run, because the two databases are not the same: on 2026-09-18 the mirror
knew none of the 89 advisories — six CRITICAL, all in ash_authentication —
that Hex listed against the v0.9.0 lock. They run:
- in CI, as its own Dependency audit job in
.github/workflows/ci.yml, and - locally as part of
mix precommit.
A new advisory affecting a locked dependency fails the build — including on a PR
that changed no dependencies, since the advisory database moves independently of
this repo. It is a separate job so that failure does not bury the lint and test
results of an unrelated change. Remediate by upgrading the dependency; if no
fixed version exists, document the accepted risk and acknowledge the advisory
(mix_audit's ignore options; the :hex section of mix.exs for hex.audit)
rather than dropping the check.
The CI job fetches the advisory database explicitly before auditing. mix_audit clones it at run time and discards the exit status of its own git commands, so a failed fetch would otherwise leave it with zero advisories and report a green "No vulnerabilities found" — a pass that verified nothing.