Skip to content

csrf:enforce never fires when the app owns persistent-login, or when basicAuth is the first security scheme #121

Description

@joewiz

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

csrf:enforce never fires when the app owns persistent-login, or when basicAuth is the first security scheme

Summary

Roaster's CSRF middleware (csrf:enforce, enabled by default in roaster:route#2) is gated on the matched authentication scheme:

declare %private function csrf:applies ($request as map(*)) as xs:boolean {
    exists(csrf:config($request)) and
    $request?method = $csrf:STATE_CHANGING_METHODS and
    $request?auth-scheme = $csrf:COOKIE_SCHEMES   (: = ("cookieAuth") :)
};

In two common configurations $request?auth-scheme is never "cookieAuth" for a cookie-authenticated request, so CSRF protection silently does nothing — a cookie-authenticated state-changing request is allowed through with no Origin/Referer check.

Cause 1 — use-basic-auth always "wins", so scheme ordering decides everything

auth:use-basic-auth returns rutil:getDBUser() unconditionally — and getDBUser() always returns a non-empty map (the guest user when nobody is authenticated). auth:use-first-matching-method stops at the first strategy that returns a user, so whichever scheme is listed first in security always becomes the matched scheme. If basicAuth is listed before cookieAuth (a natural ordering), auth-scheme is "basicAuth" for every request, including cookie-authenticated ones → csrf:applies is never true.

Cause 2 — persistent-login token consumed before use-cookie-auth runs

Reordering cookieAuth first does not fix it for the very common case where the app's controller.xq processes persistent login itself:

login:set-user("org.exist.login", xs:dayTimeDuration("P7D"), false())

eXist's persistent login uses one-time tokens (each request rotates the cookie). When the controller calls login:set-user before forwarding to Roaster, it consumes the incoming token. Roaster's auth:use-cookie-auth then calls plogin:login() on the now-spent token, gets nothing, and the request falls through to basicAuth (which returns the already-established subject via getDBUser()). So auth-scheme ends up "basicAuth" again, and CSRF is skipped.

Impact

In both configurations, csrf:enforce is present and x-csrf may be configured, yet no CSRF check is performed on cookie-authenticated writes — a false sense of protection. This affects any app that (a) lists basicAuth first, or (b) uses the standard eXist controller.xq + persistent-login pattern alongside Roaster (which is most browser-facing eXist apps).

Reproduction (roaster 1.12.1, eXist 7.0.0-beta3)

  1. An app using roaster:route#2 with both basicAuth and cookieAuth security schemes and x-csrf: { same-origin: true } declared.
  2. Authenticate via the persistent-login cookie (no Origin header).
  3. POST/PUT/DELETE a state-changing route → expected 403 (missing Origin on a cookie-auth write); actual 200/201.
  4. A cross-site Origin is likewise accepted.

Suggested direction

Gate CSRF on cookie presence, not on the matched auth-scheme:

CSRF applies when x-csrf is configured, the method is state-changing, the API declares a cookieAuth scheme, and the request actually carries that cookie — regardless of which strategy auth:use-first-matching-method happened to select.

This sidesteps both causes: it no longer depends on strategy ordering, nor on whether Roaster's own use-cookie-auth got to consume the token. (Separately, it may be worth reconsidering whether use-basic-auth should return a user unconditionally — returning the guest map makes it indistinguishable from a real Basic authentication and lets it shadow other strategies.)

Environment

  • roaster 1.12.1
  • eXist-db 7.0.0-beta3
  • Discovered while adding CSRF protection to the existdb-openapi + eXide REST surfaces.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions