[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)
- An app using
roaster:route#2 with both basicAuth and cookieAuth security schemes and x-csrf: { same-origin: true } declared.
- Authenticate via the persistent-login cookie (no
Origin header).
POST/PUT/DELETE a state-changing route → expected 403 (missing Origin on a cookie-auth write); actual 200/201.
- 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.
[This issue was co-authored with Claude Code. -Joe]
csrf:enforcenever fires when the app owns persistent-login, or whenbasicAuthis the first security schemeSummary
Roaster's CSRF middleware (
csrf:enforce, enabled by default inroaster:route#2) is gated on the matched authentication scheme:In two common configurations
$request?auth-schemeis never"cookieAuth"for a cookie-authenticated request, so CSRF protection silently does nothing — a cookie-authenticated state-changing request is allowed through with noOrigin/Referercheck.Cause 1 —
use-basic-authalways "wins", so scheme ordering decides everythingauth:use-basic-authreturnsrutil:getDBUser()unconditionally — andgetDBUser()always returns a non-empty map (the guest user when nobody is authenticated).auth:use-first-matching-methodstops at the first strategy that returns a user, so whichever scheme is listed first insecurityalways becomes the matched scheme. IfbasicAuthis listed beforecookieAuth(a natural ordering),auth-schemeis"basicAuth"for every request, including cookie-authenticated ones →csrf:appliesis never true.Cause 2 — persistent-login token consumed before
use-cookie-authrunsReordering
cookieAuthfirst does not fix it for the very common case where the app'scontroller.xqprocesses persistent login itself:eXist's persistent login uses one-time tokens (each request rotates the cookie). When the controller calls
login:set-userbefore forwarding to Roaster, it consumes the incoming token. Roaster'sauth:use-cookie-auththen callsplogin:login()on the now-spent token, gets nothing, and the request falls through tobasicAuth(which returns the already-established subject viagetDBUser()). Soauth-schemeends up"basicAuth"again, and CSRF is skipped.Impact
In both configurations,
csrf:enforceis present andx-csrfmay be configured, yet no CSRF check is performed on cookie-authenticated writes — a false sense of protection. This affects any app that (a) listsbasicAuthfirst, or (b) uses the standard eXistcontroller.xq+ persistent-login pattern alongside Roaster (which is most browser-facing eXist apps).Reproduction (roaster 1.12.1, eXist 7.0.0-beta3)
roaster:route#2with bothbasicAuthandcookieAuthsecurity schemes andx-csrf: { same-origin: true }declared.Originheader).POST/PUT/DELETEa state-changing route → expected403(missing Origin on a cookie-auth write); actual200/201.Originis likewise accepted.Suggested direction
Gate CSRF on cookie presence, not on the matched auth-scheme:
This sidesteps both causes: it no longer depends on strategy ordering, nor on whether Roaster's own
use-cookie-authgot to consume the token. (Separately, it may be worth reconsidering whetheruse-basic-authshould return a user unconditionally — returning the guest map makes it indistinguishable from a real Basic authentication and lets it shadow other strategies.)Environment