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

    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