Docs›Roteamento›CSRF protection
Roteamento

CSRF protection

Como funciona @csrf, X-CSRF-TOKEN, exempting routes.

O CSRF deste app é o nativo do Laravel — não existe um mecanismo paralelo. O middleware Illuminate\Foundation\Http\Middleware\ValidateCsrfToken (sucessor do clássico VerifyCsrfToken) já vem no grupo web e valida o token contra session()->token() — o mesmo token que o helper nativo csrf_token() e a diretiva @csrf usam. O ponto de configuração único é bootstrap/app.php.

Existe uma classe Mad\Web\Csrf no código — ela não é o mecanismo atual. Faz parte de uma stack paralela de helpers (Mad\Web\*: Session, ViewResponse, AuthHelper, BladeDirectives) que neste app full-Laravel fica inerte para CSRF — os globais view()/redirect() do Mad\Web são function_exists()-guardados e os do Laravel carregam primeiro, então nada referencia Mad\Web\Csrf fora do próprio arquivo. O @csrf que você vê em resources/views/**.blade.php (como o desta própria página) é a diretiva nativa do Blade do Laravel, compilada pelo Illuminate\View\Compilers\BladeCompiler de verdade.

Como funciona, passo a passo

1. GET /public/ged/{token}
   → Sessão Laravel já tem (ou gera) um token: session()->token()
   → Layout injeta <meta name="csrf-token" content="{{ csrf_token() }}">
   → Form usa @csrf, que emite <input type="hidden" name="_token" value="...">

2. POST /public/ged/{token}
   → Browser envia _token=... no body (ou X-CSRF-TOKEN no header, via fetch)

3. ValidateCsrfToken (grupo web, roda em toda rota de routes/web.php):
   - Lê o token apresentado (body _token / header X-CSRF-TOKEN / cookie XSRF-TOKEN)
   - Compara com session()->token()
   - Match → segue para o controller
   - Sem match → 419 (Page Expired)

Forms server-rendered tradicionais

Exemplo real deste repositório — o form de senha do link público do GED:

<!-- resources/views/ged/public/password.blade.php -->
<form method="POST" action="{{ route('ged.public.unlock', ['token' => $token]) }}">
    @csrf
    <label for="password">{{ __('ged.password_optional') }}</label>
    <input type="password" id="password" name="password" required>
    <button type="submit">{{ __('ged.open') }}</button>
</form>

Repare: é uma rota totalmente anônima (sem mad.auth) e mesmo assim tem CSRF — porque CSRF protege contra forjar uma requisição em NOME de quem quer que esteja com aquela sessão (com ou sem login), não é exclusivo de área logada.

AJAX / fetch e o ciclo reativo (MadWire)

O mesmo token do @csrf serve para chamadas JSON. O layout do portal injeta o meta tag uma vez por full-load; o mad-livewire.js lê e envia em todo POST reativo:

<!-- resources/views/public/docs-fw/layout.blade.php -->
<meta name="csrf-token" content="{{ csrf_token() }}">
// packages/mad-framework/assets/mad-livewire.js
const _csrfMeta  = document.querySelector('meta[name="csrf-token"]');
const _csrfToken = _csrfMeta ? _csrfMeta.content : '';
if (_csrfToken) _headers['X-CSRF-TOKEN'] = _csrfToken;

Tanto POST /app/_mad-wire (admin) quanto POST /public/_mad-wire (portal público, usado pela navegação reativa desta própria documentação) estão dentro do grupo web e passam pelo ValidateCsrfToken nativo normalmente — não há lógica de CSRF manual nesses controllers.

POST do header / notchbar — casco estático chamando PHP

Botões e combos do shell (trocar empresa/unidade, recarregar, sair) não passam pelo ciclo do MadWire: são fetch manuais a partir do casco. Duas regras valem para eles:

  1. A URL vem do server, montada com MadRoutes::urlFor($class, $method, $params) e emitida como data-url no elemento — o mapa outbound de rotas não é exportado para o client, e chamar a classe crua pelo nome cai fora da allowlist (404).
  2. O POST precisa levar o X-CSRF-TOKEN explicitamente: é um fetch cru, ninguém injeta o header por você.
// PHP (ShellViewModel / control do notchbar)
$url = \Mad\Routing\MadRoutes::urlFor('ChangeTenantForm', 'onChange');
// → <button data-url="/app/trocar-empresa/onChange">
const res = await fetch(btn.dataset.url, {
    method: 'POST',
    headers: {
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        'X-Requested-With': 'XMLHttpRequest',
    },
    body: new FormData(form),
});
await Mad.applyOps(await res.json()); // aplica o MadResponse no DOM

Sem o header, a rota (que está no grupo web como qualquer outra do routes/web.php) devolve 419. Ver Rotas públicas para o contrato completo do urlFor().

MadCsrf — defesa em profundidade no endpoint do admin

Além do middleware nativo, MadAppController::wire() (só o endpoint admin, /app/_mad-wire) chama Mad\Security\MadCsrf::validateWire() antes de processar a ação:

// packages/mad-framework/src/mad/security/MadCsrf.php
class MadCsrf
{
    /** Token CSRF da sessão Laravel — o MESMO que csrf_token(). */
    public static function token(): string
    {
        $session = app('session');
        if (!$session->token()) {
            $session->regenerateToken();
        }
        return (string) $session->token();
    }

    /** Lê o token do client: header X-CSRF-TOKEN, body _token/mad_csrf, header X-XSRF-TOKEN. */
    public static function readFromRequest(): string { /* ... */ }

    public static function check(?string $provided = null): bool { /* compara com hash_equals */ }

    /** Só EXIGE token para sessão LOGADA — fluxo anônimo (login/cadastro) passa sem token,
     *  o estado criptografado do MadWire já cobre esse caso. */
    public static function validateWire(): bool
    {
        if (!session('logged')) {
            return true;
        }
        return self::check();
    }
}

MadCsrf::token() é literalmente session()->token() do Laravel — "CSRF unificado" quer dizer isso: um único token, uma única fonte de verdade. O validateWire() é só uma checagem extra explícita no controller do admin, não uma segunda implementação.

Excluindo rotas do CSRF (webhooks, integrações stateless)

O ponto de configuração é $middleware->validateCsrfTokens(except: [...]) em bootstrap/app.php. Hoje as únicas exceções são os dois grupos de iframe cross-origin do Copilot embed e do Mad Agent Command Center:

// bootstrap/app.php
$middleware->validateCsrfTokens(except: [
    'embed/v1/*',
    'agent-console/v1/*',
]);
Rota sem CSRF precisa de outra forma de autenticação. As duas exceções acima não ficam "abertas": ambas exigem Authorization: Bearer (token MCP, validado por McpManifestAuthMiddleware) em todo request — CSRF protege sessão-cookie, e esses fluxos simplesmente não usam cookie de sessão para autenticar. Nunca exponha uma rota mutável sem algum mecanismo de auth.

A camada /api/* não precisa de exceção

As rotas de routes/api.php (API pública stateless, atrás do middleware mad.api) não aparecem na lista de except — e nem precisam: elas não estão no grupo web. O withRouting(api: …) as registra no grupo api do Laravel, que tem só SubstituteBindings: sem StartSession, portanto sem ValidateCsrfToken.

// bootstrap/app.php
->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',   // grupo `api`: sem sessão, sem CSRF
    ...
)

Ali a autenticação é inteiramente o Authorization: Bearer mad_api_… resolvido pelo mad.api (token → unit → tenant → banco), com abilities declaradas por rota — mesmo princípio das exceções acima: sem cookie de sessão, CSRF não se aplica. Detalhes em REST API — Autenticação e Middleware stack. Endpoints /app/api/* internos (atrás de mad.auth, dentro do routes/web.php) continuam no grupo web — esses exigem o token CSRF normalmente.

Fontes aceitas do token (ordem de leitura)

#FonteUso típico
1Header X-CSRF-TOKENmad-livewire.js e qualquer fetch/AJAX manual
2Body _tokenForms HTML tradicionais via @csrf
3Cookie XSRF-TOKEN → header X-XSRF-TOKENClients HTTP que respeitam o cookie automático do Laravel

Erro comum — 419 Page Expired

<!-- ERRADO: form sem @csrf — POST sempre falha com 419 -->
<form method="POST" action="/public/ged/{{ $token }}">
    <input type="password" name="password">
</form>

<!-- CERTO -->
<form method="POST" action="/public/ged/{{ $token }}">
    @csrf
    <input type="password" name="password">
</form>

Outra causa comum: sessão expirada/regenerada (ex: depois de login) deixando o token do meta tag antigo na página aberta há tempo — nesse caso o fix é recarregar a página antes de submeter, não desabilitar CSRF.

Helpers e diretivas — todos nativos do Laravel

Helper/diretivaRetornoUso
csrf_token()stringToken atual da sessão (gera se ausente).
csrf_field()stringHTML do <input hidden> — o que @csrf emite por baixo.
@csrfdiretiva BladeDentro de <form method="POST">.
Mad\Security\MadCsrf::token()stringMesmo token, usado por ShellViewModel/MadAppController quando o código já está no contexto Mad\*.

Próximos passos

  • Middleware stack — onde ValidateCsrfToken entra no pipeline.
  • Rotas públicas — declarando rotas que herdam o grupo web (e portanto CSRF) automaticamente.
  • REST API — Autenticação — endpoints internos usam sessão (mad.auth) e portanto CSRF; a API pública /api/* (mad.api) é stateless e autentica por Bearer, sem CSRF.