Skip to content

Proposal: declarative CSRF protection for cookie-authenticated routes #117

Description

@joewiz

[This response was co-authored with Claude Code. -Joe]

Summary

Propose adding declarative, opt-in CSRF protection to Roaster, enforced only when the matched security scheme is cookie-based. This builds directly on the cookie-login polish in v1.12.0 and closes a real gap that affects every Roaster app accepting state-changing requests from a browser.

Motivating threat model

The publish-package endpoint in eXist's public-repo (and any analogous admin-upload endpoint) is a textbook CSRF target: a logged-in dba who visits a malicious page can be tricked into POSTing a multipart form that installs an arbitrary XAR — i.e. arbitrary code execution on the eXist server.

Survey of the current state (2026-05-17):

  • eXide (on Roaster): no CSRF protection. Uses cookieAuth+basicAuth with x-constraints: { groups: ["dba"] } per route. controller.xq reads X-Requested-With only to decide 401 rendering, not as a gate.
  • monex (not on Roaster): no CSRF protection.
  • public-repo (not on Roaster): no CSRF protection.

Public-repo will ship an inline check in publish-package.xq for the eXist 7 beta next week, but adding the same logic to every app in turn duplicates work and misses future Roaster apps. Roaster already resolves which security scheme matched per request — exactly the signal needed to apply CSRF only where it matters (cookie auth) and skip it where it doesn't (Basic auth from CLI clients like xst / packageservice).

Proposed design

Token-less, Origin/Referer-based, OpenAPI-native. Fits the existing security / x-constraints patterns.

Spec syntax — leaning toward a per-route x-csrf extension that mirrors x-constraints:

paths:
  /publish:
    post:
      x-csrf:
        same-origin: true
        allowed-origins: ["https://exist-db.org"]

A top-level csrf: default with per-route override is the obvious alternative, but the per-route extension reads more like the rest of Roaster's vocabulary and keeps the spec local to the endpoint that needs protection. Happy to be argued out of it.

Behavior:

  1. Middleware fires only when the matched security scheme is cookie-based. Basic-auth requests bypass entirely.
  2. When active: read Origin (preferred), fall back to Referer. Parse scheme+host.
  3. same-origin: true → require match against the request's own scheme/host/port.
  4. allowed-origins: → require membership in the list.
  5. On mismatch → 403 with Roaster's standard error shape.
  6. Missing both Origin and Referer on a cookie-auth state-changing request → 403.

Where it slots in: after auth resolution (so the matched scheme is known), before the route handler runs — same lifecycle slot as x-constraints group enforcement.

Implementation note

Today auth:authenticate in content/auth.xql propagates the resolved user onto the request but not the matched scheme name. A small change there to record e.g. request?auth-scheme is the natural foundation for the scheme-aware gate; the CSRF middleware then keys off it.

Why this design

  • Token-less. OWASP-recommended "simple, robust CSRF defense" for state-changing endpoints. No token store, no template cooperation needed.
  • Backwards compatible. Off by default; existing apps see no change.
  • CLI-friendly. The auth-scheme gate is the key insight — xst, packageservice, curl --user keep working untouched.
  • One line of OpenAPI config to protect a whole app.

Proposed deliverables (if accepted)

  1. Middleware module in content/.
  2. OpenAPI spec extension parsing for x-csrf.
  3. XQSuite tests: cookie+matching-origin pass; cookie+mismatched-origin 403; cookie+missing-origin 403; basic-auth bypass; no-config bypass.
  4. README / doc/cookie-auth.md update.

Happy to do the implementation work on a feature branch once the shape is agreed. /cc @line-o

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions