On this page
  1. The boundary table
  2. Who may edit
  3. What each role may reach
  4. What a save can write
  5. What an author’s markdown can render
  6. What the operational logs can reveal
  7. Trust boundaries, end to end
  8. Reporting a vulnerability

The security model

cairn draws trust boundaries around a site: who may open the admin, what each signed-in role may reach once inside, what a save may write to the repo, and what an author’s markdown may render in a visitor’s browser. Each boundary below states the guarantee, the risk it leaves uncovered, and where the full mechanics live when a sibling page owns them.

The boundary table

Boundarycairn handlesYour site handles
Who may editMagic-link delivery, single-use tokens, session rows, CSRF on every unsafe requestThe allowlist itself (who holds which of your declared roles), and swapping in a different identity provider if you want one
What each role may reachEnforcing the declared access map at the route, and deriving sidebar visibility from the same checkDeclaring which roles reach which screens and routes
What a save can writeAuthor/committer separation, branch confinement, path confinement to your declared conceptsWhich repo the GitHub App installs on, branch protection on main, who reviews what lands there
What an author’s markdown can renderThe sanitize floor (scripts, event handlers, dangerous URL schemes stripped before delivery)Your own render() function and any component registry you add to it

Who may edit

An editor never has a GitHub account, a password, or anything cairn has to store and hash. Signing in means clicking a link in email, and everything the boundary needs to enforce sits behind that one action.

Requesting a link looks an email up against the D1 allowlist. A match mints a random 256-bit token, hashes it before it ever touches storage, and stores only the hash with a 10-minute expiry. Confirming the link consumes the token in one atomic D1 statement: the row is deleted and the email returned in the same query, so a token that’s already been used or has expired simply isn’t there to consume, and two confirms racing the same link can’t both succeed. A confirmed token creates a session row (30-day expiry) and sets a session cookie, __Host- prefixed on HTTPS, HttpOnly, and scoped so no script on the page can read it. Every admin request resolves that cookie against the live session row, joined to the editor’s current role, and maps it to one of the engine’s three capability levels (owner, editor, or none). Because that happens on every request, a role change or a removed editor takes effect on the very next one rather than waiting for a stale session to expire. A role your config no longer declares still authenticates; it just resolves to no content access, and never locks the person out of signing in.

The request path never confirms or denies whether an email is on the allowlist: an unknown address gets the identical response as a known one, so the login form can’t be used to probe who’s an editor. The one exception is a deliberate trade the design accepts: a repeated request within a minute returns a distinct throttled response, which does reveal membership, in exchange for not flooding a real editor’s inbox.

CSRF sits at the same boundary, because a stolen session cookie is not the whole attack. An attacker still has to get the editor’s browser to submit a request it never meant to send. cairn owns CSRF for every unsafe request under /admin, verified before any route handler runs: a form post carries a double-submit token that has to match a session-scoped, HttpOnly, SameSite=Strict cookie, and a raw-body request (the media upload, which can’t spare a form field) proves the same token through a custom request header instead, which a cross-origin page can’t set without triggering a CORS preflight it would fail. The admin guard also serves as the one place that decides an unauthenticated visitor doesn’t get past /admin/login, refuses a deployed request served over plain HTTP (a magic link only works if the session cookie can be set), and refuses outright if the auth database binding is missing, rather than rendering a login form that can never resolve. Every response the guard lets through carries a baseline of hardening headers regardless: no framing, no sniffing, no referrer, HSTS.

A crafted ?error= link is inert. Every admin load that can receive an ?error= value resolves it against a small internal vocabulary before it ever reaches a component, and drops anything it doesn’t recognize; no admin screen puts the raw query string in front of an editor, whether in a plain alert or an aria-live region a screen reader announces immediately. This closes a link-crafting surface: eight loads used to read ?error= and render its value verbatim inside a branded alert, so a link carrying an arbitrary sentence in the query string could put attacker-chosen text inside the CMS’s own voice, on a page the editor already trusts. The login and confirm pages carry the two remaining raw ?error= reads; both treat the value as a boolean flag, showing or hiding one fixed, engine-authored sentence, never the query value itself.

Residual risk. The email account is now the credential. Anyone who reads an editor’s inbox in the ten minutes after a request can claim their session, which is the trade every magic-link system makes in exchange for never asking a non-technical editor to manage a password.

What each role may reach

Authenticating only decides whether a session exists; a site with more than one kind of editor also needs to decide what that session may do. cairn’s floor is the three capability levels (owner, editor, none): an owner-capability role reaches everything, a none-capability role reaches nothing cairn’s own surfaces guard, and an editor-capability role reaches every one of cairn’s own screens unless the site narrows it further. A site declares that narrowing once, as an access map: a target, a screen id or one of its own /admin-prefixed routes, to the role names admitted to it.

The map’s authority is one function, canReach, and every enforcement point and every visibility check reads it: the engine’s own route gates (a direct URL to a restricted screen answers 403, never a silent redirect), the requireAccess helper a site’s own custom route calls, and the sidebar resolver. Reading the same function everywhere is the guarantee, not an implementation convenience: a system that decides “what renders” and “what’s allowed” in two separately maintained places is how a hidden menu item quietly becomes an open, unguarded route, a vulnerability class documented widely enough to have its own name (hide-in-UI-is-not-authorization, OWASP A01). Nav placement is never the authorization here; the map, checked at the route, is.

Residual risk. The map narrows by role name, not by row: it can say “a club-admin reaches the money screens” but not “this instructor reaches only their own class,” since that needs state the map doesn’t carry (which class, which instructor). A site that needs row-level scoping still owns that check itself, inside the route the map already gated to the right role.

An href target derived from event.route.id matches the deepest path-segment-prefix key the map declares. Under a dynamic route segment (/admin/money/[report]), a key deeper than the segment can never literally match it, so a site that keys both /admin/money and a stricter /admin/money/payroll, meaning the second to override the first, would see every request the dynamic segment serves resolve against the shallower rule instead: the deeper rule dead code, with no error at declaration time. requireAccess and createSectionAction both refuse this shape outright (403, owner included) rather than silently admitting through the shallower key, so the site’s own misconfiguration surfaces as a lockout to fix, not a quiet overgrant. A site relying on per-value authorization under a dynamic segment needs a declared target and its own check inside the route; the map’s key-matching alone can’t express it.

What a save can write

A save is a Git commit, made through a GitHub App rather than a stored personal token. The commit’s author is the signed-in editor (read from their verified session, never from request input), and the committer is left to the App, so git log shows exactly who wrote a line and that cairn is the tool that landed it. Cairn confines every write it makes to the concept directories your adapter declares, and every save lands on the entry’s own holding branch (cairn/<concept>/<id>) rather than main, so nothing an editor writes reaches a reader until a deliberate publish copies it across. The engine’s connection to GitHub only ever reads files, commits changes, and manages branches; there’s no query surface for it to leak through.

A related guarantee covers the content graph a save writes into: a reference field’s target has to actually exist, and the build refuses outright rather than silently linking to nothing. Reference integrity owns how a rename rewrites every pointing field and why a delete is blocked while anything still references the entry.

Residual risk. The GitHub App’s installation token can write to any path in the repo it’s installed on; the confinement to your concept directories is enforced by cairn’s own code at the call site, not by GitHub’s permission model. Installing the App on a repo that also holds things you don’t want cairn touching widens that risk more than installing it on a dedicated content repo would.

What an author’s markdown can render

Every path from a markdown file to a byte a browser executes runs through the same rendering pipeline, whether it’s the editor’s live preview or a visitor’s page. That pipeline applies a sanitize floor built from the same allowlist GitHub uses to render markdown safely: scripts, inline event handlers, and javascript:/data: URLs are stripped regardless of what a site adds on top, and a site’s own extensions can only add to that allowlist, never weaken it. The render sanitize floor documents that guarantee block by block, including how a site’s own renderer inherits it.

Residual risk. The floor stops an author’s markdown from executing script in a visitor’s browser. It says nothing about what an author is allowed to write in the first place, since any role with editor capability is trusted by cairn’s design, per who may edit; a site that needs to defend against a malicious editor, rather than an accidental one, needs its own review step before publish.

What the operational logs can reveal

cairn emits a structured log record for most events worth diagnosing (a failed send, a rejected guard request, a commit that didn’t land), and every one of them is written to be safe to paste into an incident channel. A record carries an editor’s email for attribution, and never a magic-link token, a session id, or the contents of the link, even when the record is logging a failure and the temptation is to log everything about it. The log events reference is the exhaustive table of what each event fires on and carries.

Trust boundaries, end to end

%%{init: {"theme": "neutral"}}%%
flowchart LR
    editor["Editor's browser"] -- magic link, session cookie --> guard["Admin guard (session + CSRF + origin + HTTPS)"]
    guard -- verified editor --> commit["Commit pipeline (author = editor, committer = the App)"]
    commit -- installation token --> repo["Repo: main + cairn/&lt;concept&gt;/&lt;id&gt; branches"]
    repo -- publish copies to main --> deploy["Your site's deploy"]
    deploy --> render["Render pipeline (sanitize floor)"]
    visitor["Visitor's browser"] -- requests a page --> render
    render -- sanitized HTML --> visitor

Two browsers cross into cairn here, and neither is trusted by default. The editor’s browser is gated by the admin guard and writes only to a holding branch; the visitor’s browser never reaches the admin and receives only what the render pipeline already sanitized.

Reporting a vulnerability

Found something this page doesn’t account for? See SECURITY.md for how to report it privately.

Edit this page on GitHub(opens in a new tab)