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) + }) + }) +})