Accepted

ADR 0058 — Security audit remediation

Decisions taken while fixing a multi-agent security audit of the platform, and the items deliberately deferred

Context

A security audit was run against the platform using a multi-agent methodology (reconnaissance, parallel hunting by attack class and subsystem, adversarial validation, and independent verification), then a second run weighted toward the areas the first run touched lightly. The audit confirmed a critical plugin-sandbox escape, several authentication and authorisation gaps, a stored-XSS filter bypass, and a number of lower-severity issues. This decision records the choices made while fixing them — most fixes are mechanical, but a handful encode a genuine design decision worth pinning.

Decisions

Plugin D1 sandbox is the containment boundary, so its parser must fail closed. The per-plugin D1 proxy validated table references with a hand-rolled parser that only collected tables following FROM/INTO/JOIN/UPDATE keywords; a SQLite implicit cross-join (FROM a, b) introduced a second table after a comma that slipped past the allowlist and executed against the global database. The parser now rejects comma-joined table lists outright (explicit JOIN remains the only multi-table form). Separately, the physical table namespace plugin_{id}_{table} was non-injective because the kebab-case id had its hyphens rewritten to underscores while declared table names could also contain underscores; declared table names now forbid underscores, making the namespace unambiguous.

The Author role enforces document ownership, and per-collection authoring is deferred. Document write paths (update, discard-draft, preview-token, live edit session, create) gated only on role-slug membership, so an Author could act on any user's document. They now require ownership (author_id equals the actor) for non-manager roles; Editor and Administrator remain content managers over all documents. The role model also names per-collection permissions (Author = posts only), but the route layer cannot enforce that today: dynamically-created collections have no permission rows, so consulting the permissions table would block managers from custom collections entirely. Per-collection authoring restrictions are therefore deferred to the custom-roles work; ownership is the boundary v1 enforces, and it is the one the role description ("manage their own content") actually promises.

Session tokens are rejected on the headless bearer surface. Since the session/api-token unification, a logged-in session is an api_tokens row with a frozen scopes=['*'] bound to a user. The cookie carrier re-loads the user and re-checks deletion and roles on every request; the Authorization-header bearer flow did not, so a deleted or role-demoted user could present their own session value as a bearer and keep full access on /api/v1. The headless bearer flow now rejects kind='session' tokens entirely — service tokens (kind='service') are the only credential for the headless API, and the admin surface only ever accepted sessions via the cookie. As a structural backstop, session revocation (revoking the api_tokens rows and purging their KV fast-path cache) is now folded into usersRepo.changePassword and usersRepo.softDelete, so every deprovisioning caller — including the admin password-reset and user-delete paths that previously only cleared the empty legacy sessions table — evicts sessions by construction.

The public search index carries block text only, not per-field data. The FTS body was built by flattening a document's blocks and every per-field data value. The public /api/v1/search endpoint is anonymous and returns snippet windows of that body, so it exposed the text of custom fields a theme never renders to unauthenticated callers, defeating both the theme's field selection and the headless token gating. The index now flattens block content only — the prose every theme renders into the public page. Admin search matches the same corpus. A per-field 'searchable' opt-in that would let an operator re-include specific data fields is a future enhancement.

Takedowns bump the global cache epoch. The cache epoch is the platform's cross-colo invalidation primitive, but it was bumped only by settings, theme, setup, and seed changes — not by content takedown. Unpublishing or trashing a published document evicted only the local colo's cache, leaving other colos serving the removed page for up to its s-maxage. Unpublish (admin) and trash (headless /api/v1 DELETE) now bump the epoch, delete the rendered artefact, and deindex from search so a takedown is prompt and global. The publish workflow also re-checks that the document is still published before re-materialising its rendered artefact, closing a publish-then-unpublish race that could resurrect taken-down content.

Also fixed

  • Stored XSS: the render-time hostile-href check used trim()+startsWith, which missed control-character-obfuscated schemes (java\tscript:) that browsers strip and execute. It now removes C0 controls and ASCII whitespace before the scheme test.
  • Version history: snapshots of setting, api_token, and plugin_config entities are now administrator-only and credential fields are redacted, so a non-admin cannot read plugin-config secrets or token hashes through the version API.
  • Collaboration presence: the DocumentSession Durable Object now stamps the server-trusted identity onto awareness broadcasts and the admin reads identity from that envelope, so an editor can no longer impersonate another peer by forging their awareness payload.
  • Headless _meta: the OpenAPI document is scope-filtered to the collections a token can read, and is no longer shared-cached, so a narrowly-scoped token cannot enumerate every collection's schema.
  • Account enumeration: the password-reset request path now does equivalent work on both the user-exists and user-absent branches, removing a timing oracle.
  • A collection slug of 'search' is reserved (it would otherwise be shadowed by the public search route), and media responses carry X-Content-Type-Options: nosniff and a locked-down Content-Security-Policy.

Deferred (with rationale)

  • A baseline Content-Security-Policy on the public render path. It is the strongest defence-in-depth against any future render-escape, but a strict script-src would break trusted third-party themes that use inline scripts; the stored-XSS vector it would have backstopped is now fixed at source. Adding a per-theme-negotiated CSP is tracked separately.
  • Re-architecting the customise live-preview iframe to a cross-origin or postMessage model. It is same-origin with allow-same-origin and allow-scripts, so it amplifies any public-render content XSS into the admin origin — but it requires a content XSS to exploit, and the known one is fixed. The cross-origin move is a non-trivial UI refactor and is tracked as hardening.
  • Rejecting a document slug whose permalink collides with another collection's listing path. It requires an editor to deliberately craft a colliding slug, the impact is content overwrite within one's own site, and a cross-collection collision check is more machinery than the low severity warrants for v1.

Consequences

  • The plugin D1 sandbox holds against the comma-join escape and the namespace collision, restoring the isolation guarantee the whole plugin trust model depends on.
  • Deprovisioning (password reset, user delete, role demotion) is now effective on both the admin and headless surfaces; the Author role's advertised boundary is enforced.
  • Anonymous search and the public render path no longer expose content beyond what a theme renders, and takedowns are globally prompt.
  • Three items are deferred with explicit rationale rather than rushed; each is defence-in-depth on top of a root-cause fix that has already landed.