Skip to content

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:

<form method="post" th:action="@{/admin/catalogue/product/delete}">

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()).