Hardened by default.
CSRF authenticity tokens, signed and encrypted cookies, sessions backed by a cookie or a server-side store, parameter filtering for logs, transport and host security middleware, secret resolution, and an encrypted credentials vault. The defaults are safe; the knobs are there when you need them.
#CSRF protection
protect-from-forgery registers a before-action that verifies a token on every
unsafe verb (POST, PUT, PATCH, DELETE). The
session holds one real token; each csrf-token call masks it with a fresh one-time
pad, so the rendered value differs every time while still validating. A request may also supply
the token in the X-CSRF-Token header.
class ApplicationController is MVC::Keayl::Controller { }
ApplicationController.protect-from-forgery;
# in a form
form-with(
model => $post,
url => '/posts',
csrf-token => self.csrf-token,
content => -> $f {
$f.text-field('title')
});
A failed check follows the with strategy: exception (the default)
raises X::MVC::Keayl::InvalidAuthenticityToken, which becomes a 422;
reset-session and null-session clear the session and let the request
proceed without the prior identity. API and webhook endpoints opt out with
skip-forgery-protection.
#sessions
MVC::Keayl::Session is the per-request session, loaded from a store at the start and
persisted after the action runs. Access is indifferent, and an untouched session persists
nothing. reset-session empties it, which you call on privilege changes such as login
and logout.
# write
self.session<user-id> = $user.id;
# read
my $id = self.session<user-id>;
# remove
self.session<user-id>:delete;
# on login and logout
self.reset-session;
The default cookie store serializes the session to a signed cookie (tamper-evident); set its
serializer to encrypted to keep the contents confidential too. The server-side store
keeps only a signed session id in the cookie and the data in a pluggable backend.
MVC::Keayl::Session::CookieStore.new(
serializer => 'encrypted');
my $store =
MVC::Keayl::Session::ServerSideStore.new(
backend =>
MVC::Keayl::Session::MemoryBackend.new,
);
self.flash<notice> = 'Saved' survives
exactly one redirect, then is dropped. flash.now shows a message in the current
request only, and add-flash-types registers named types like
flash.success(...).
#cookies
MVC::Keayl::Cookies is the cookie jar, built from the request's Cookie
header and flushed to the response as Set-Cookie headers after the action.
set takes the attributes: path, domain,
expires, max-age, secure, http-only, and
same-site.
# read
self.cookies<theme>;
# write
self.cookies<theme> = 'dark';
# delete
self.cookies.delete('theme');
self.cookies.set('session', $id,
path => '/',
http-only => True,
same-site => 'Lax',
secure => True);
cookies.signed is a tamper-evident jar: a written value gets an HMAC-SHA1 signature,
and reading verifies it with a constant-time comparison, returning Nil if it does
not match. cookies.encrypted keeps the value confidential, encrypting with
AES-256-CBC under a key derived from the controller's secret, then authenticating
the ciphertext (encrypt-then-MAC).
self.cookies.signed<user-id> = $user.id;
# the value, or Nil if tampered
my $id = self.cookies.signed<user-id>;
self.cookies.encrypted<card> = $number;
# Nil if it cannot be authenticated
my $number = self.cookies.encrypted<card>;
#parameter filtering
MVC::Keayl::ParameterFilter redacts sensitive parameters so they are safe to log,
replacing any matching key's value with [FILTERED] and recursing through nested
hashes and arrays. The defaults cover the usual names: passw, secret,
token, _key, crypt, salt,
certificate, otp, and ssn.
# { password => '[FILTERED]', name => 'Ada' }
MVC::Keayl::ParameterFilter.new.filter(
%( password => 'secret', name => 'Ada' ));
class PaymentsController is MVC::Keayl::Controller { }
PaymentsController.filter-parameters(
'pin', 'cvv');
# the request params, redacted, safe to log
self.filtered-params;
also adds names to the defaults and filters replaces them; a filter may
be a string or a regex. Filtering copies the parameters, so the controller's params
are untouched.
#transport and host security
Three middleware harden requests in transit. SSL redirects plain requests to HTTPS
and adds an HSTS header, taking the scheme from X-Forwarded-Proto behind a proxy.
HostAuthorization blocks requests whose Host is not on an allowlist.
SecureHeaders adds default security headers without overriding any the application
already set.
MVC::Keayl::Middleware::SSL.new(
app => $inner,
hsts => True,
hsts-max-age => 31536000,
include-subdomains => True);
MVC::Keayl::Middleware::HostAuthorization.new(
app => $inner,
allowed => ['example.com', '.example.com',
/\.internal$/]);
MVC::Keayl::Middleware::SecureHeaders.new(
app => $inner);
# X-Frame-Options: SAMEORIGIN,
# X-Content-Type-Options: nosniff,
# Referrer-Policy:
# strict-origin-when-cross-origin, ...
#HTTP authentication
The controller provides Basic, Token, and Digest schemes. Every credential comparison uses
secure-compare, a constant-time check that never short-circuits on the first
differing byte. authenticate-or-request-with-http-basic runs a block and, when it
returns falsy, issues the 401 challenge.
method show {
if self.authenticate-or-request-with-http-basic(
'Admin', &check) {
self.render(plain => 'welcome');
}
}
DashboardController.http-basic-authenticate-with(
name => 'admin',
password => 'secret',
realm => 'Admin',
except => <index>,
);
#secrets and credentials
MVC::Keayl::Secrets resolves the application secret from config, then
KEAYL_SECRET_KEY_BASE, then SECRET_KEY_BASE, dying if none is set so a
misconfigured deployment fails loudly. derive-key produces a purpose-specific key
for a salt, deterministic for the same base and salt, so one base backs the signing and
encryption keys from distinct salts.
my $secrets = MVC::Keayl::Secrets.resolve;
# 64 hex chars
$secrets.derive-key('signed cookie');
# from one salt
$secrets.signing-key;
# from another
$secrets.encryption-key;
MVC::Keayl::Credentials stores secrets in an encrypted YAML file checked into the
repository, decrypted at runtime with a master key from KEAYL_MASTER_KEY or
config/master.key. keayl credentials-edit opens the decrypted file in
$EDITOR and re-encrypts whatever you save.
my $credentials = MVC::Keayl::Credentials.resolve;
$credentials<secret-key-base>;
# Nil if any key is missing
$credentials.read('aws', 'access-key-id');
my $production = MVC::Keayl::Credentials.resolve(
env => 'production');