diff --git a/README.md b/README.md
index c7d3656..886595c 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,32 @@ The *effective user* will be used, if present.
This will work also for custom authorization strategies. The handler function needs to [extend the request map with the user information](test/app/modules/jwt-auth.xqm#L66).
+## CSRF Protection
+
+Cookie-authenticated state-changing endpoints (e.g. an admin-only XAR upload) are a textbook CSRF target: a logged-in dba who visits a malicious page can be tricked into POSTing to the protected endpoint with their cookie attached.
+
+Roaster ships an opt-in, declarative CSRF middleware. Enable it per route with the `x-csrf` extension:
+
+```json
+"/publish": {
+ "post": {
+ "summary": "Install a XAR",
+ "x-csrf": {
+ "same-origin": true
+ }
+ }
+}
+```
+
+Behaviour:
+
+- The check fires **only** when the matched security scheme is `cookieAuth` **and** the method is `POST`, `PUT`, `PATCH`, or `DELETE`. Basic-auth requests from CLI clients (`xst`, `packageservice`, `curl --user`) bypass entirely.
+- `same-origin: true` requires the request's `Origin` header (or, as a fallback, `Referer`) to match the server's own scheme, host, and port.
+- `allowed-origins: [...]` accepts an explicit list of trusted origins (e.g. `["https://exist-db.org"]`); the parsed origin must be a member.
+- Cookie-auth state-changing requests with no `Origin` and no `Referer` are rejected with 403.
+
+Routes without `x-csrf` are unaffected. The middleware can also be configured at the spec top level if every state-changing route in the API should be protected.
+
## Middleware
If you need to perform certain actions on each request you can add a transformation function also known as middleware.
diff --git a/content/auth.xql b/content/auth.xql
index 8e35844..89e9725 100644
--- a/content/auth.xql
+++ b/content/auth.xql
@@ -260,28 +260,33 @@ declare %private function auth:authenticate ($request as map(*), $response as ma
let $methods := array:for-each($defined-auth-methods, auth:map-auth-methods(?, $strategies))
- let $user := array:fold-left($methods, (), auth:use-first-matching-method($request))
+ let $match := array:fold-left($methods, map{}, auth:use-first-matching-method($request))
+ let $user := $match?user
let $constraints := $request?config?x-constraints
return
if (
- auth:is-public-route($constraints) or
+ auth:is-public-route($constraints) or
auth:is-authorized-user($constraints, $user)
)
then (
- map:put($request, "user", $user), (: add "user" to request :)
+ (: add "user" and matched "auth-scheme" to request :)
+ map:merge((
+ $request,
+ map { "user": $user, "auth-scheme": $match?scheme }
+ )),
$response
)
else error($errors:UNAUTHORIZED, "Access denied")
};
-declare %private function auth:map-auth-methods ($method-config as map(*), $strategies as map(*)) as function(*) {
+declare %private function auth:map-auth-methods ($method-config as map(*), $strategies as map(*)) as map(*) {
let $method-name := map:keys($method-config)
(: TODO handle method-parameters for OAuth and openID
: let $method-parameters := $method-config?($method-name) :)
-
+
return
if (map:contains($strategies, $method-name))
- then ($strategies($method-name))
+ then map { "name": $method-name, "fn": $strategies($method-name) }
else error(
$errors:OPERATION,
"No strategy found for : '" || $method-name || "'", ($method-config, $strategies)
@@ -289,10 +294,15 @@ declare %private function auth:map-auth-methods ($method-config as map(*), $stra
};
declare %private function auth:use-first-matching-method ($request as map(*)) as function(*) {
- function ($user as map(*)?, $method as function(*)) as map(*)? {
- if (exists($user))
- then $user
- else $method($request)
+ function ($acc as map(*), $method as map(*)) as map(*) {
+ if (map:contains($acc, "user"))
+ then $acc
+ else
+ let $user := $method?fn($request)
+ return
+ if (exists($user))
+ then map { "user": $user, "scheme": $method?name }
+ else $acc
}
};
diff --git a/content/csrf.xqm b/content/csrf.xqm
new file mode 100644
index 0000000..5c098dc
--- /dev/null
+++ b/content/csrf.xqm
@@ -0,0 +1,160 @@
+(:
+ : Copyright (C) 2026 TEI Publisher Project Team
+ :
+ : This program is free software: you can redistribute it and/or modify
+ : it under the terms of the GNU General Public License as published by
+ : the Free Software Foundation, either version 3 of the License, or
+ : (at your option) any later version.
+ :
+ : This program is distributed in the hope that it will be useful,
+ : but WITHOUT ANY WARRANTY; without even the implied warranty of
+ : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ : GNU General Public License for more details.
+ :
+ : You should have received a copy of the GNU General Public License
+ : along with this program. If not, see .
+ :)
+xquery version "3.1";
+
+(:~
+ : CSRF protection middleware for cookie-authenticated routes.
+ :
+ : Activated declaratively via an `x-csrf` extension in the OpenAPI
+ : spec, either per-route or at the top level. Only enforced when the
+ : matched security scheme is `cookieAuth` and the request method is
+ : state-changing (POST, PUT, PATCH, DELETE). Basic-auth requests from
+ : CLI clients bypass.
+ :
+ : Configuration shape:
+ : x-csrf:
+ : same-origin: true # require Origin/Referer to match request host
+ : allowed-origins: ["https://example.org"] # optional explicit allow-list
+ :
+ : Behaviour on a cookie-auth state-changing request:
+ : - Origin (preferred) or Referer must be present.
+ : - If `same-origin: true`, the parsed origin must equal the request's own
+ : scheme/host/port.
+ : - If `allowed-origins` is set, the parsed origin must be a member.
+ : - On mismatch or missing header: 403.
+ :)
+module namespace csrf="http://e-editiones.org/roaster/csrf";
+
+import module namespace request="http://exist-db.org/xquery/request";
+import module namespace errors="http://e-editiones.org/roaster/errors";
+
+declare %private variable $csrf:STATE_CHANGING_METHODS := ("post", "put", "patch", "delete");
+
+declare %private variable $csrf:COOKIE_SCHEMES := ("cookieAuth");
+
+(:~
+ : Middleware entry point. Conforms to the Roaster middleware contract:
+ : takes (request, response) and returns (request, response).
+ :)
+declare function csrf:enforce ($request as map(*), $response as map(*)) as map(*)+ {
+ if (csrf:applies($request))
+ then (
+ csrf:check($request, csrf:config($request)),
+ $request, $response
+ )
+ else (
+ $request, $response
+ )
+};
+
+(:~
+ : Resolve effective x-csrf config: per-route wins, falls back to spec-level.
+ :)
+declare %private function csrf:config ($request as map(*)) as map(*)? {
+ head((
+ $request?config?x-csrf,
+ $request?spec?x-csrf
+ ))
+};
+
+(:~
+ : True when this request is in scope for CSRF enforcement.
+ :)
+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
+};
+
+(:~
+ : Perform the origin check. Throws errors:FORBIDDEN on failure.
+ :)
+declare %private function csrf:check ($request as map(*), $config as map(*)) as empty-sequence() {
+ let $origin := csrf:header-origin()
+ return
+ if (empty($origin)) then
+ error($errors:FORBIDDEN,
+ "CSRF check failed: missing Origin and Referer headers on cookie-authenticated " ||
+ upper-case($request?method) || " request")
+ else if ($config?same-origin and $origin ne csrf:request-origin())
+ then
+ error($errors:FORBIDDEN,
+ "CSRF check failed: Origin '" || $origin ||
+ "' does not match request host '" || csrf:request-origin() || "'")
+ else if (
+ exists($config?allowed-origins) and
+ not($origin = csrf:allowed-origins($config))
+ )
+ then
+ error($errors:FORBIDDEN,
+ "CSRF check failed: Origin '" || $origin || "' is not in the allowed-origins list")
+ else ()
+};
+
+(:~
+ : Origin/Referer of the incoming request, normalised to "scheme://host[:port]".
+ : Origin is preferred (RFC 6454); Referer is a fallback because some user
+ : agents and proxies strip Origin from same-origin requests.
+ :)
+declare %private function csrf:header-origin () as xs:string? {
+ let $raw := head((
+ request:get-header("Origin"),
+ request:get-header("Referer")
+ ))
+ return
+ if (empty($raw) or $raw = ("", "null"))
+ then ()
+ else csrf:normalize-origin($raw)
+};
+
+(:~
+ : Strip path/query/fragment from a URL, leaving "scheme://host[:port]".
+ :)
+declare %private function csrf:normalize-origin ($url as xs:string) as xs:string {
+ let $match := analyze-string($url, "^([a-zA-Z][a-zA-Z0-9+.\-]*://[^/\?#]+).*$")
+ let $group := $match//*:group[@nr="1"]/string()
+ return
+ if (exists($group)) then $group else $url
+};
+
+(:~
+ : Compute the request's own origin from eXist's request module.
+ :)
+declare %private function csrf:request-origin () as xs:string {
+ let $scheme := request:get-scheme()
+ let $host := request:get-server-name()
+ let $port := request:get-server-port()
+ let $default-port :=
+ ($scheme = "http" and $port = 80) or
+ ($scheme = "https" and $port = 443)
+ return
+ if ($default-port)
+ then $scheme || "://" || $host
+ else $scheme || "://" || $host || ":" || string($port)
+};
+
+(:~
+ : Allowed-origins may be a JSON array or a single string.
+ :)
+declare %private function csrf:allowed-origins ($config as map(*)) as xs:string* {
+ let $value := $config?allowed-origins
+ return
+ typeswitch ($value)
+ case array(*) return $value?*
+ case xs:string return $value
+ default return ()
+};
diff --git a/content/roaster.xqm b/content/roaster.xqm
index 6c082fc..e895e18 100644
--- a/content/roaster.xqm
+++ b/content/roaster.xqm
@@ -25,6 +25,7 @@ import module namespace router="http://e-editiones.org/roaster/router";
import module namespace errors="http://e-editiones.org/roaster/errors";
import module namespace parameters="http://e-editiones.org/roaster/parameters";
import module namespace auth="http://e-editiones.org/roaster/auth";
+import module namespace csrf="http://e-editiones.org/roaster/csrf";
(:~
: May be called from user code to send a response with a particular
@@ -77,7 +78,10 @@ declare function roaster:resolve-pointer ($config as map(*), $ref as xs:string*)
: 3. If two paths have the same (normalized) length, prioritize by appearance in API files, first one wins
:)
declare function roaster:route($api-files as xs:string+, $lookup as function(xs:string) as function(*)?) {
- router:route($api-files, $lookup, auth:standard-authorization#2)
+ router:route($api-files, $lookup, (
+ auth:standard-authorization#2,
+ csrf:enforce#2
+ ))
};
declare function roaster:route($api-files as xs:string+, $lookup as function(xs:string) as function(*)?, $middleware) {
diff --git a/doc/cookie-auth.md b/doc/cookie-auth.md
index 0e5c7d5..3cf1ddc 100644
--- a/doc/cookie-auth.md
+++ b/doc/cookie-auth.md
@@ -239,3 +239,11 @@ cookie:set(map {
| `samesite` | | one of `"None"`, `"Lax"`, or `"Strict"` |
| `secure` | | mark the cookie as secure |
| `httponly` | | sets the HttpOnly property |
+
+## CSRF protection for cookie-authenticated routes
+
+Because the browser will attach the login cookie to any same-origin request — including ones initiated by an attacker's page via a form submission or `fetch` — state-changing endpoints that rely on cookie auth need protection against cross-site request forgery (CSRF).
+
+Roaster's `x-csrf` extension is the recommended defence (see the `CSRF Protection` section in the [README](../README.md)). It is opt-in per route, fires only when the matched security scheme is `cookieAuth`, and leaves Basic-auth flows from CLI clients untouched.
+
+Setting the `samesite` cookie attribute to `"Lax"` or `"Strict"` is a complementary defence, but it is not a substitute: `Lax` still allows top-level navigations and is implemented inconsistently across older browsers. The Origin/Referer check enforced by `x-csrf` is the more reliable backstop for security-sensitive endpoints.
diff --git a/expath-pkg.xml.tmpl b/expath-pkg.xml.tmpl
index 4c7616c..6275b59 100644
--- a/expath-pkg.xml.tmpl
+++ b/expath-pkg.xml.tmpl
@@ -34,4 +34,8 @@
http://e-editiones.org/roaster/cookie
cookie.xqm
+
+ http://e-editiones.org/roaster/csrf
+ csrf.xqm
+
diff --git a/test/app/api.json b/test/app/api.json
index 0acb7dd..f1d1b1f 100644
--- a/test/app/api.json
+++ b/test/app/api.json
@@ -1225,6 +1225,54 @@
}
}
}
+ },
+ "/api/csrf/same-origin": {
+ "post": {
+ "summary": "CSRF-protected route requiring same-origin",
+ "operationId": "rutil:debug",
+ "x-csrf": {
+ "same-origin": true
+ },
+ "tags": [
+ "auth"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/csrf/allowed-origins": {
+ "post": {
+ "summary": "CSRF-protected route with explicit allow-list",
+ "operationId": "rutil:debug",
+ "x-csrf": {
+ "allowed-origins": ["https://allowed.example"]
+ },
+ "tags": [
+ "auth"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/test/csrf.test.js b/test/csrf.test.js
new file mode 100644
index 0000000..c3d75b0
--- /dev/null
+++ b/test/csrf.test.js
@@ -0,0 +1,104 @@
+const util = require('./util.js')
+const chai = require('chai')
+const expect = chai.expect
+
+const adminAuth = {
+ username: util.adminCredentials.username,
+ password: util.adminCredentials.password
+}
+
+describe('CSRF protection', function () {
+
+ describe('on a route with x-csrf same-origin', function () {
+
+ before(util.login)
+ after(util.logout)
+
+ it('allows cookie-auth POST with matching Origin', async function () {
+ const res = await util.axios.post('api/csrf/same-origin', {})
+ expect(res.status).to.equal(200)
+ })
+
+ it('rejects cookie-auth POST with mismatched Origin', async function () {
+ try {
+ await util.axios.post('api/csrf/same-origin', {}, {
+ headers: { Origin: 'https://evil.example' }
+ })
+ expect.fail('expected request to be rejected with 403')
+ } catch (e) {
+ expect(e.response.status).to.equal(403)
+ }
+ })
+
+ it('rejects cookie-auth POST with no Origin or Referer', async function () {
+ try {
+ await util.axios.post('api/csrf/same-origin', {}, {
+ headers: { Origin: null, Referer: null }
+ })
+ expect.fail('expected request to be rejected with 403')
+ } catch (e) {
+ expect(e.response.status).to.equal(403)
+ }
+ })
+
+ it('falls back to Referer when Origin is absent', async function () {
+ const res = await util.axios.post('api/csrf/same-origin', {}, {
+ headers: { Origin: null, Referer: util.axios.defaults.baseURL + '/anything' }
+ })
+ expect(res.status).to.equal(200)
+ })
+ })
+
+ describe('with basic auth', function () {
+
+ it('bypasses CSRF check even with mismatched Origin', async function () {
+ const res = await util.axios.post('api/csrf/same-origin', {}, {
+ auth: adminAuth,
+ headers: { Origin: 'https://evil.example', Cookie: null }
+ })
+ expect(res.status).to.equal(200)
+ })
+ })
+
+ describe('on a route with x-csrf allowed-origins', function () {
+
+ before(util.login)
+ after(util.logout)
+
+ it('allows cookie-auth POST when Origin is in the list', async function () {
+ const res = await util.axios.post('api/csrf/allowed-origins', {}, {
+ headers: { Origin: 'https://allowed.example' }
+ })
+ expect(res.status).to.equal(200)
+ })
+
+ it('rejects cookie-auth POST when Origin is not in the list', async function () {
+ try {
+ await util.axios.post('api/csrf/allowed-origins', {}, {
+ headers: { Origin: 'https://not-allowed.example' }
+ })
+ expect.fail('expected request to be rejected with 403')
+ } catch (e) {
+ expect(e.response.status).to.equal(403)
+ }
+ })
+ })
+
+ describe('on a route with no x-csrf config', function () {
+
+ before(util.login)
+ after(util.logout)
+
+ it('does not enforce CSRF even with mismatched Origin', async function () {
+ // POST /api/paths/{path} is authed (x-constraints user=admin)
+ // but has no x-csrf, so the middleware must bypass.
+ const res = await util.axios.post('api/paths/whatever', 'hello', {
+ headers: {
+ Origin: 'https://evil.example',
+ 'Content-Type': 'text/plain'
+ }
+ })
+ expect(res.status).to.equal(201)
+ })
+ })
+})