Sessions and security

The web-security basics are keywords; each is implemented in C inside the dispatcher, so none of them add a Perl frame to the request. Signing users in on top of them is Authentication.

Sessions

session
    secret   => secret('session_key'),   # from the secrets system
    expires  => '7d',
    samesite => 'Lax';

$c->session is then a hashref. By default it is written back to an HMAC-SHA256-signed cookie when it changes:

post '/login' => sub {
    my ($c) = @_;
    $c->session_rotate;                   # a new id at the privilege change
    $c->session->{user_id} = $user->id;
    $c->redirect('/');
};

post '/logout' => sub { my ($c) = @_; $c->session_expire; $c->redirect('/') };

Source the key from the secrets system, never a literal - session croaks at boot without one. Options: secret, cookie (default punk.sid), expires, path, domain, secure, httponly (default on), samesite (default Lax), and store below. Also configurable from punk.yml.

expires is a real lifetime, not just a Max-Age: the expiry is stamped inside the signed payload and checked when the cookie is read, so a cookie past it is refused whatever the client chose to keep.

A cookie session has two ceilings that are the cookie's, not the session's: it caps at ~4KB, and the client can read (not forge) the contents - signed is not sealed, so no secrets in it. And $c->session_expire can only ask the browser in front of it to forget; a copy somebody took stays good until its stamped expiry. All three go away with a store.

Server-side sessions

cache 'file', dir => '/var/cache/app';

session secret  => secret('session_key'),
        expires => '7d',
        store   => 'cache';               # the payload moves off the client

store keeps the session server-side in a Punk::Cache store; the cookie carries a signed 128-bit id and nothing else. $c->session is unchanged, so no application code moves. What changes:

  • The ~4KB ceiling goes (replaced by a 1MB one, because a session an application can grow without limit is a way to fill a store from a login form).
  • The client can no longer read the contents.
  • $c->session_expire revokes: it deletes the entry, so a cookie somebody copied is dead on its next request. That is the difference a store buys, and the reason it exists.

store takes the default store ('cache'), the name of a store cache declared, a hashref describing one, or a store object - so sessions in Redis or your database are a backend somebody writes once against the five-method contract. A store that is not shared between workers is refused at boot: the session written on worker A would be absent on worker B, a logout at random. allow_unshared => 1 says there is only one process (tests, punk dev).

$c->session_rotate keeps the session, gives it a new id and deletes the entry under the old one. Call it at the privilege boundary - a login. A cookie session is immune to fixation by accident (its value changes wholesale with its contents); a stored session is not, because the id survives the login unless something changes it. Without a store it is a documented no-op.

Two more store options:

session ..., store => 'cache', sliding => 1, tier => 2;

sliding extends a session that is in use, throttled - the extension happens once the session is past half its lifetime, not on every request, because sliding on every request is a store write per request. tier lets sessions be read through the store's memory tier, which they otherwise go round: the number is how many seconds you accept a revocation lagging on a worker that missed the invalidation. It is capped at 5 and checked against the store's own memory_ttl at boot.

Flash

One-request messages riding the session - set, redirect, read once:

post '/save' => sub {
    my ($c) = @_;
    $c->flash(notice => 'Saved.');       # for the NEXT request
    $c->redirect('/list');
};

get '/list' => sub {
    my ($c) = @_;
    $c->render('list', { flash => $c->flash });
};

$c->flash(key => $value) sets messages for the next request, $c->flash('key') reads one of this request's inbound messages, a bare $c->flash returns the whole inbound hashref, and $c->flash_keep re-arms them for one more request - the redirect-through-a-redirect case. Reads see the previous request's messages, writes feed the next one's; the two never meet in one request.

CSRF

session secret => secret('session_key');
csrf;

Single-use tokens over the session: every unsafe request must carry a live token, and using one spends it.

get '/edit' => sub {
    my ($c) = @_;
    return $c->render('edit', { csrf => $c->csrf_field });
};

post '/edit' => sub {
    my ($c) = @_;          # only runs if the token was good
    ...;
};
<form method="post" action="/edit">
  {% raw csrf %}
  ...
</form>

$c->csrf_field is the hidden input, $c->csrf_token the bare value; the token is also mirrored into a script-readable cookie for fetch calls. Options: keep (how many live tokens to allow) and exempt (path prefixes to skip - webhooks, callbacks).

CORS

cors;                                   # a public API: *, no credentials
cors origins => [ 'https://apphtbprolexamplehtbprolcom-p.hcv9jop5ns4r.cn' ], credentials => 1,
     paths   => [ '/api' ];

Handled inside the dispatcher: preflights are answered before routing - no OPTIONS route needed - and the headers reach every response, including the 404s and 405s that never build a context. Access-Control-Allow-Methods comes from the router itself, so it cannot promise a method the application does not serve.

Security headers

headers;                                # the safe default set

headers 'Strict-Transport-Security' => 'max-age=31536000',
        'X-Frame-Options'           => 'DENY';

headers (Punk::Headers) adds a frozen set of response headers to everything the application sends, from inside the dispatcher - so, like CORS, they reach the 404s and 405s that never build a context. The bare keyword ships only what is safe on any application:

X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin

CSP and HSTS are deliberately not defaults - both belong to the application, spelled out. A static Content-Security-Policy line here is worth having; the policy that actually stops cross-site scripting is script-src 'nonce-...', and a per-request nonce threaded into every <script> tag cannot be a config line - that is Punk::Plugin::CSP:

plugin 'CSP';
<script nonce="{% csp_nonce %}">start()</script>

The nonce reaches the template on its own - nothing passed by the handler - and $c->csp_nonce is the same value for HTML built by hand. Its defaults include object-src 'none' and base-uri 'none', the two directives that close the bypasses a script-only policy leaves open. One rule to hold on to: a response that rendered a nonce must never be cached, by anything.

Headers are set-if-absent: a handler's own $c->header wins, and a value of undef drops one from the default set. An under scope can carry its own policy for its subtree:

my $admin = under '/admin' => $guard;
$admin->headers('X-Frame-Options' => 'DENY');

Most specific wins per name: the response's own header, then the longest-prefix scope, then the application-wide policy. Also configurable from a headers: block in punk.yml.

Rate limiting and blocking

rate_limit limit => 300, window => 60;              # everything, by client IP

rate_limit for => '/api', by => 'header:X-Api-Key',
           limit => 60, window => 60, tag => 'api';

rate_limit (Punk::RateLimit) answers 429 Too Many Requests with Retry-After and the X-RateLimit-* headers. The counters live in Hyperman's shared memory arena, mapped before the workers fork, so a limit is exact across the whole pool rather than per worker. by is the client IP by default, a header, or a coderef; declare it more than once for layered limits.

Blocking is separate and cheaper: $c->block_ip(undef, 3600) adds the current client to the arena's denylist and Hyperman drops it at accept - before a byte is read - on its next connection. Everything fails open: without Hyperman under the application the limiter allows every request, so it is never the reason a good request is refused.

Behind nginx, an ELB or a CDN, declare proxy first or the limiter is wrong in a way that takes the site down: REMOTE_ADDR is then the proxy's address on every request, every client lands in one shared bucket, and block_ip bans the load balancer.

The rest of the posture

  • Controller targets, template engines and spec mounts all resolve at boot - a misconfigured app refuses to start rather than serve.
  • Secrets are references resolved from outside the config file, shown as [redacted] everywhere - see Config and secrets.
  • Static and markdown mounts refuse .. path segments.
  • Uploads spill to a private temp file, never trust the client's filename, and can be scanned - see Uploads and files.