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_expirerevokes: 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.