NAME

PAGI::Middleware::CSRF - Cross-Site Request Forgery protection middleware

SYNOPSIS

use PAGI::Middleware::Builder;

my $app = builder {
    enable 'CSRF',
        secret       => 'your-secret-key',
        token_header => 'X-CSRF-Token',
        cookie_name  => 'csrf_token',
        safe_methods => ['GET', 'HEAD', 'OPTIONS'];
    $my_app;
};

# Issue-only mode: the middleware never rejects; the app validates via
# $ctx->csrf_verify once it has parsed the submitted form/JSON params.
my $app2 = builder {
    enable 'CSRF', secret => 'your-secret-key', enforce => 'app';
    $my_app;
};

DESCRIPTION

PAGI::Middleware::CSRF provides protection against Cross-Site Request Forgery attacks by validating tokens on state-changing requests.

CONFIGURATION

  • secret (required)

    Secret key used for token generation.

  • token_header (default: 'X-CSRF-Token')

    Header name to look for the CSRF token.

  • cookie_name (default: 'csrf_token')

    Cookie name for the CSRF token.

  • safe_methods (default: ['GET', 'HEAD', 'OPTIONS', 'TRACE'])

    HTTP methods that don't require CSRF validation.

  • secure (default: 0)

    Add the Secure attribute to the CSRF cookie, restricting it to HTTPS requests. Off by default so plain-HTTP development setups keep working; for production HTTPS deployments, add secure => 1.

  • enforce (default: 'header')

    How unsafe methods (anything not in safe_methods) are checked:

    • 'header' - the middleware itself validates: the request must carry a token_header whose value matches the cookie token, or the middleware responds 403 and the app is never called. This only works for requests that can set a custom header (typically AJAX/fetch); a plain HTML form POST has no way to add one, so a server-rendered form under this mode would always 403 -- see "USAGE" for why.

    • 'app' - issue-only. The middleware mints/persists the cookie token exactly as it does for safe methods, on every method, and never auto-rejects. It stashes the cookie token (the existing one, or a freshly minted one if none existed yet) into scope as csrf_token for the app to read via $ctx->csrf_token. The app owns validation, by calling $ctx->csrf_verify($submitted) once it has parsed the request's params, and decides the response for a failed check. This is what server-rendered form POSTs need.

USAGE

The CSRF middleware always uses a double-submit cookie pattern: a token is generated and stored in an HttpOnly cookie, and a request is only valid if it also carries that same token some other way -- because HttpOnly means client-side JavaScript cannot read the cookie itself (document.cookie won't show it, and neither would a hypothetical getCookie helper). That "some other way" is where the two enforce modes diverge.

Header flow (enforce => 'header', the default)

Use this for JSON/AJAX APIs, where the client can set a custom request header. The middleware validates the header itself; the app is never called on a mismatch.

Render the token into the page once (a <meta> tag is the usual spot), reading it from the context helper -- not from the cookie, which JavaScript cannot see:

<meta name="csrf-token" content="<%= $ctx->csrf_token %>">

Then have client-side script read the meta tag and send it back as the configured header:

const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/resource', {
    method: 'POST',
    headers: { 'X-CSRF-Token': token },
});

Form flow (enforce => 'app')

Use this for server-rendered HTML forms. A plain <form> POST has no way to add a custom header, so enforce = 'header'> would 403 every such submission -- that's precisely why this mode exists: the middleware only issues the token (on every method, including the POST itself) and never auto-rejects; the app validates once it has parsed the submitted params.

Embed the token as a hidden field:

<input type="hidden" name="_csrf_token" value="<%= $ctx->csrf_token %>">

Then, in the handler, verify the submitted value against the one the middleware stashed in scope:

return $ctx->text('CSRF token validation failed', status => 403)
    unless $ctx->csrf_verify($params->{_csrf_token});

See "csrf_token" in PAGI::Context and "csrf_verify" in PAGI::Context for the helper reference.

SEE ALSO

PAGI::Middleware - Base class for middleware

"csrf_token" in PAGI::Context, "csrf_verify" in PAGI::Context - context helpers for reading and checking the token, used by the enforce => 'app' flow