#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,
);
Flash rides the session. 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');