Back-office CSRF protection¶
Every shop on a shared domain (*.bdgr.co.uk, *.bdgr-demo.co.uk) is the same site to a
browser. The session cookie is SameSite=Lax, which stops other websites, but not another
shop's storefront: a page on shop-b.bdgr.co.uk could post a form to shop-a.bdgr.co.uk/admin/...
and the browser would send shop A's admin cookie with it. Storefront HTML is in the merchant's
hands, so the back office checks a CSRF token on every request that changes something.
What is protected¶
A POST, PUT, PATCH or DELETE to any of these needs the session's token:
| Path | What it is |
|---|---|
/admin/** |
The admin, including its JSON APIs (/admin/api/**) and uploads |
/system/**, /secured/** |
Superadmin pages |
/fulfilment/** |
The fulfiller portal and Store today |
/api/inline-edit/** |
The inline block editor on the storefront (admin only) |
GET requests never need it, so a GET must never change anything: use a form or a script
that posts.
Everything else served by the web app is deliberately left alone:
| Not checked | Why |
|---|---|
The storefront (basket, checkout, account, forms, /api/checkout/**...) |
Out of scope for the back-office fix; it acts with a shopper's own powers, not an admin's |
/login |
So a sign-in page left open past the session's life can't trap anyone |
/logout |
Signing out by link keeps working (signing someone out is a nuisance at worst) |
/oauth2/authorization/**, /login/oauth2/code/** |
The central sign-in: GET redirects, protected by OAuth2's own state |
/stepup-return |
Carries a signed, single-use proof instead (see Admin Stepup) |
Payment and shipping webhooks, /mcp, /media-upload/{token} |
Authenticated by a signature, bearer token or one-time link, not the session cookie |
There are no exemptions inside the protected paths: everything there is reached with the session cookie.
The rules live in AdminCsrf (web-admin), applied by SecurityConfig. The token is kept in
the session (it never starts a session on a storefront page just to hold one) and is replaced at
sign-in.
Forms¶
Draw every back-office form that posts with th:action; Thymeleaf then adds a hidden _csrf
field:
A form that posts back to its own page (action=""), or one in a template that render tests
process without a web context (where @{/...} can't resolve), adds the field itself:
<form method="post" th:object="${product}" action="">
<input type="hidden" th:if="${_csrf != null}" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
A plain action="/admin/..." gets no token, and the form fails with "This page has expired" the
first time it is used. AdminFormsCarryCsrfTokenTest fails the build on a POST form in an
admin, system, fulfilment or admin extension template that has neither.
Scripts¶
Nothing to do. The admin, system and fulfilment heads (admin/csrf.html) render the token as
meta tags and load admin-csrf.js before anything else. It adds the token, as the
X-CSRF-TOKEN header, to every fetch() and XMLHttpRequest (so jQuery, Dropzone and
summernote) that changes something on the same origin, and a _csrf field to forms built in
script, including form.submit(). Requests to other origins (Stripe, S3) are never given the
token.
If you need the token directly: BadgerCsrf.get() returns {token, headerName, parameterName}
and BadgerCsrf.headers() an object to merge into request headers. A script on a page without
the admin head (the storefront's inline editor) asks GET /admin/api/csrf, which answers the same
JSON.
When a token is refused¶
A refused request gets a 403 with the header X-CSRF-Rejected: true:
- a script gets
{"success": false, "error": "csrf", "message": "..."}; - a posted form gets a short page saying the page has expired, with a link back to the page it was posted from (reloaded, so it has a fresh token).
The usual causes are innocent: the 30-minute session timed out, taking its token with it, or
the person signed in again in another tab, which replaces the token. The admin script recovers
where it can. It asks GET /admin/api/csrf for the current token; if the person is still signed
in, it retries a refused fetch() once, and after a refused XMLHttpRequest it asks them to try
again. If they are signed out, it asks them to reload the page (and sign in). A form posted after
more than 20 idle minutes renews the token first, so a long edit isn't refused for a token that
was replaced in the meantime.
Second check: where the request came from¶
AdminSameOriginFilter also refuses a change to the protected paths that the browser says came
from somewhere else: Sec-Fetch-Site other than same-origin, or, from browsers that don't send
it, an Origin (or Referer) for another host. Another shop on the same domain is same-site,
not same-origin, so it is refused. A request with none of these headers is left to the token.
Testing¶
AdminCsrfSecurityTest runs the real configuration through Spring Security's MockMvc support.
In a MockMvc test of a back-office endpoint, add the token with Spring Security's request
post-processor: post("/admin/...").with(csrf()), or .with(csrf().asHeader()).