Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 20 additions & 10 deletions content/auth.xql
Original file line number Diff line number Diff line change
Expand Up @@ -260,39 +260,49 @@ 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)
)
};

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
}
};

Expand Down
160 changes: 160 additions & 0 deletions content/csrf.xqm
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
:)
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 ()
};
6 changes: 5 additions & 1 deletion content/roaster.xqm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions doc/cookie-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions expath-pkg.xml.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@
<namespace>http://e-editiones.org/roaster/cookie</namespace>
<file>cookie.xqm</file>
</xquery>
<xquery>
<namespace>http://e-editiones.org/roaster/csrf</namespace>
<file>csrf.xqm</file>
</xquery>
</package>
48 changes: 48 additions & 0 deletions test/app/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
}
}
}
}
Loading