Static caching: `/!/csrf` and `/!/nocache` race on first visit, page token and session cookie come from different sessions, form POST returns 419
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 42/100
- Tipo de issue
- Error
- Claridad
- Bien especificado
- Estado de actividad
- Activo
- Stack tecnológico
- javascript, laravel, php
- Área
- authentication, backend, frontend
Línea de trabajo
Start with src/StaticCaching/Replacers/CsrfTokenReplacer.php (lines 78-84) and NoCacheReplacer.php (line 97), then the inline scripts generated in src/StaticCaching/Cachers/FileCacher.php (getCsrfTokenJs and getNocacheJs). Reproduce with the Playwright snippet in the issue, comparing the token source and cookie source on a fresh context. Done when both endpoints return the same session, or the token and cookie come from the same response, and a form POST on a first visit is accepted.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Bug description
The form POST returns 419 Page Expired. A visitor who already has a session cookie (from any earlier non cached request) is not affected, which makes the bug look intermittent.
Measured in headless Chromium, 4 out of 4 fresh profiles: input[name=_token] held the token from the /!/csrf response, the cookie jar held the *_session cookie from the /!/nocache response. The two values belong to different sessions, so the token can never validate.
Expected: The form POST is accepted, Laravel validates the _token against the session referenced by the session cookie.
How to reproduce
- Enable full static caching and warm the cache for a page that has a form and a
{{ nocache }}region. - Open that page in a fresh browser (no cookies for the site).
- Submit the form right away.
Logs
No server log entry. In the browser: POST /!/forms/<handle> returns 419 although it carries a valid looking X-CSRF-Token. The /!/csrf and /!/nocache responses each set a different *_session cookie.
Environment
Environment
Laravel Version: 12.69.3
PHP Version: 8.4
Statamic
Version: 6.35.1 PRO
Static Caching: full (production)
Statamic Addons
studio1902/statamic-peak-tools: 9.0.3
studio1902/statamic-peak-seo: 11.2.0
studio1902/statamic-peak-commands: 10.6.0
ademti-software/form-protection: 1.1.0
Frontend
laravel-precognition 2.0.0, laravel-precognition-alpine 2.0.0
Installation
Starter Kit using via CLI
Additional details
On a cached page two inline scripts run, each firing a POST immediately on execution, with no ordering between them.
src/StaticCaching/Replacers/CsrfTokenReplacer.php injects the csrf script at the top of <head>:
// lines 78-84
$insertBefore = collect([
Str::position($contents, '<link'),
Str::position($contents, '<script'),
Str::position($contents, '</head>'),
])->filter()->min();
$contents = Str::substrReplace($contents, $cacher->getCsrfScript(), $insertBefore, 0);
src/StaticCaching/Replacers/NoCacheReplacer.php injects the nocache script before </body>:
// line 97
$contents = str_replace('</body>', $cacher->getNocacheScript().'</body>', $contents);
Both scripts are IIFEs that call fetch() synchronously on execution. From src/StaticCaching/Cachers/FileCacher.php:
// getCsrfTokenJs(), lines 255-263
(function() {
fetch('/!/csrf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
.then((response) => response.json())
.then((data) => {
for (const input of document.querySelectorAll('input[value="$csrfPlaceholder"]')) {
input.value = data.csrf;
}
// getNocacheJs(), lines 328-339
var map = createMap();
fetch('{$nocacheUrl}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: window.location.href.split('#')[0],
sections: Object.keys(map)
})
})
.then((response) => response.json())
.then((data) => {
The nocache script never touches _token inputs, although its controller also returns the token (src/StaticCaching/NoCache/NoCacheController.php, line 34: 'csrf' => csrf_token(),). The csrf controller returns only the token (src/StaticCaching/NoCache/CsrfTokenController.php, lines 9-11).
Both routes run inside the web middleware group with the session middleware, only CSRF verification is removed (routes/web.php, lines 105-111). For a first-time visitor neither request carries a session cookie, so Laravel starts a separate session for each. Each response ends with its own Set-Cookie: <app>_session=..., and the browser keeps whichever arrives last. The nocache response renders the regions and is larger, so in practice it lands last. The page then holds:
input[name=_token]: token of session A (from/!/csrf)- cookie jar: session B (from
/!/nocache)
VerifyCsrfToken compares the posted token against session B and rejects it.
On the second page load the cookie exists, both requests reuse session B and everything lines up, which is why the bug only hits the first page a visitor loads. It also only shows when the page has both a form and a nocache region, otherwise only one script is injected.
Playwright snippet showing the mismatch without submitting
import { chromium } from 'playwright';
const url = 'https://example.test/page-with-form-and-nocache';
const browser = await chromium.launch();
const context = await browser.newContext(); // fresh cookie jar
const page = await context.newPage();
const seen = {};
page.on('response', async (res) => {
const path = new URL(res.url()).pathname;
if (path === '/!/csrf' || path === '/!/nocache') {
const body = await res.json();
const setCookie = res.headers()['set-cookie'] ?? '';
seen[path] = {
csrf: body.csrf,
sessionCookie: (setCookie.match(/[a-z0-9_]+_session=([^;]+)/i) ?? [])[1],
};
}
});
await page.goto(url, { waitUntil: 'networkidle' });
const pageToken = await page.$eval('input[name=_token]', (el) => el.value);
const jarCookie = (await context.cookies(url)).find((c) => c.name.endsWith('_session'))?.value;
console.log({ seen, pageToken, jarCookie });
console.log('token source:', Object.entries(seen).find(([, v]) => v.csrf === pageToken)?.[0]);
console.log('cookie source:', Object.entries(seen).find(([, v]) => v.sessionCookie === jarCookie)?.[0]);
await browser.close();
Typical output on a fresh context: token source: /!/csrf, cookie source: /!/nocache. The two csrf values differ.
Proposed fix
Any one of these removes the race, listed from least to most invasive:
- Make the nocache script also update the token from its own response. It already receives
data.csrf; applying the same replacement loop the csrf script uses (input[value="STATAMIC_CSRF_TOKEN"],meta[content=...],script[data-csrf=...], plus the Livewire hooks) would make the last response win for both token and cookie. Downside: if the csrf response arrives last, the inputs still get the csrf token while the cookie is nocache's, so this only helps when nocache lands last, which is the common case but not guaranteed. Better: have the nocache script set the token only when the csrf one has not yet replaced it, and vice versa, with the later one overwriting. Since the cookie is also last-wins, consistently last-wins on both sides fixes it. - Chain the requests. When both scripts are present, let the nocache script wait for
statamic:csrf.replaced(the event the csrf script already dispatches, FileCacher.php line 291) before calling/!/nocache. The second request then carries the cookie from the first and both responses share one session. Costs one round trip of latency on nocache regions. - Serve the token from the same request. Drop the separate
/!/csrfcall when a nocache script is present and let the nocache response handle token replacement. Largest change, but one request and no ordering concerns.
Option 1 with a consistent last-wins rule is the smallest change. Option 2 is the most robust without restructuring the endpoints.
A reproduction can be verified with the Playwright snippet above: after the fix, token source and cookie source must name the same endpoint, or the csrf values in both responses must be equal.
- Lenguaje dominante
- PHP
- Estrellas
- 4.9k
- Forks
- 647
- Merge medio
- 1 d 19 h
- PR fusionados (30 d)
- 122
Preparar el entorno
Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de statamic/cms
-
Make native filesystem directory permissions configurable to support shared POSIX ACL accessAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
Los mantenedores suelen responder en 1 día
-
accessibility
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
Los mantenedores suelen responder en 1 día
-
[7.x] Reintroduce parser error for mixed pipe/shorthand modifier syntaxPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abiertoantlers
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
Los mantenedores suelen responder en 1 día
-
navigations
Dificultad 3/5 1-2 días Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
CP Cache Utility: Opening the cache manager times out when the Glide Cached Presets are too bigAbiertoutilities
Dificultad 4/5 3-5 días Aptitud para principiantes 35/100
statamic/cms#15623 · 2 reacciones ·
Los mantenedores suelen responder en 1 día
Todos los issues de statamic/cms
Issues similares
-
InvoiceSettled is answered 200 when the invoice cannot be re-fetched, so BTCPay never redelivers itAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
btcpayserver/woocommerce-greenfield-plugin#93 ·
Los mantenedores suelen responder en 1 día
-
kind/bug status/triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
-
🐛 [Bug] Bulk CSV import stores hide_from_combined_view=FALSE as a string, which hides every account from Sent, Junk, Trash and DraftsPosiblemente ocupada @exSnake la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
opnsense/core#11052 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
needs triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100