Skip to content

Pramnos Security Guide

Security is a core concern in Pramnos Framework v1.2. This guide covers built-in security features and best practices.

CSRF Protection

There are two CSRF tokens, and both are verified with hash_equals():

Token Emitted by Verified by
Form token — a field whose name is the session token and whose value is hash_hmac('sha256', $userAgent [. $ip], $sessionToken) Session::getTokenField() Session::checkToken() / checkTokenValue()
Synchronizer token — 256 random bits kept in the session CsrfMiddleware::tokenField(), <meta name="csrf"> Session::verifyCsrfToken(), CsrfMiddleware

The account controllers, the settings form and the scaffolded templates use the form token; neither is the weaker choice. The session token behind the form token is 256 bits (random_bytes(32)); a shorter one found in an existing session is replaced on its next request.

Synchronizer-token methods on Session:

Method Description
getCsrfToken(): string Returns/generates the synchronizer CSRF token (256-bit)
verifyCsrfToken(string $submitted): bool Timing-safe comparison via hash_equals()
regenerateCsrfToken(): void Regenerate the CSRF token (call after login/logout)

Controller actions that change something: addWriteAction()

An action that deletes, revokes, removes, resets, sends or saves must not be reachable by a GET link or by a POST without a token: any page a signed-in user opens can make either — an <img src="…/users/delete/5"> needs no click, and a form on another site needs no token. A data-confirm dialog guards against a slip of the hand, not against a request nobody made.

Declare those actions once, in the constructor:

public function __construct(?\Pramnos\Application\Application $application = null)
{
    $this->addAuthAction(['display', 'edit', 'save', 'delete', 'lock']);
    $this->addWriteAction(['save', 'delete', 'lock']);
    parent::__construct($application);
}

Controller::exec() then refuses a declared action before it runs unless it arrives as a POST carrying a token this session issued — the form token (Session::getTokenField()), the synchronizer token as _csrf_token (CsrfMiddleware::tokenField()), or the synchronizer token as the X-CSRF-Token header a page's own fetch() sends from <meta name="csrf">. The action needs no check of its own. A refused page request gets an error message and is sent back to where it came from on this site; a script (Accept: application/json or X-Requested-With) gets 403 and a JSON reason.

In the view, a link to such an action becomes a one-button form:

echo \Pramnos\Html\Icon::postButton(adminUrl('users/delete/') . $id, 'delete', 'Delete',
    ['data-confirm' => 'Delete this user?']);                // an icon, as Icon::link() drew it

echo \Pramnos\Html\Icon::postControl(adminUrl('users/lock/') . $id, 'Lock account',
    ['class' => 'btn btn-sm', 'data-confirm' => 'Lock this account?']);   // a labelled button

Both carry the session's token and work without JavaScript; data-confirm still asks first where pf-utils.js is loaded.

data-confirm asks at the click on a link or a button. On a <form> it asks once, when the form is sent — a click on a field, a box or a label inside the form asks nothing — and not at all when the button that sent it carries its own, which has asked already.

An action that shows a page on GET and changes something on POST — a confirmation screen that posts to itself — is not declared, because the declaration would refuse the page too. Guard its POST branch instead:

if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$this->isVerifiedWrite()) {
    $this->refuseUnverifiedWrite();
    return null;
}

The bundled views are held to the declarations by tests/Unit/Application/WriteActionViewsContractTest.php: a link to a declared write action, an Icon::link() to one, or a form posting to one without a token fails it. A controller's new write action is covered as soon as it is declared.

CsrfMiddleware

Validates the token on POST, PUT, PATCH, DELETE. Passes GET, HEAD, OPTIONS, TRACE through unchecked, and also passes requests whose first path segment is exempt — unsubscribe always (a mail client's one-click unsubscribe carries no token), plus any listed in the constructor's $exemptPaths.

// Global protection for all state-changing routes
$router->addGlobalMiddleware(new CsrfMiddleware());

// Per-route
$router->post('/transfer', fn() => ...)
       ->middleware(new CsrfMiddleware());

Token lookup order per request: 1. $_POST[$fieldName] (default field: _csrf_token) 2. X-CSRF-Token request header

In HTML Forms

<!-- Synchronizer token field -->
<?php echo \Pramnos\Http\Middleware\CsrfMiddleware::tokenField(); ?>
<!-- → <input type="hidden" name="_csrf_token" value="…" /> -->

AJAX / Fetch

A signed-in page already carries the synchronizer token: the document's <head> emits <meta name="csrf" content="…"> whenever a session is running and somebody is signed in (Document::csrfHeadMarkup()). Send it back as a header:

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

pf-utils.js, pf-auth.js and pf-consent.js read the same tag. On an anonymous page there is no tag — reading the token would start a session on every public URL — so a page that must post without a sign-in prints the field itself with CsrfMiddleware::tokenField().

A page left open: the token is refreshed

A form keeps the token it was drawn with. If somebody signs in in another tab, or the session expires while the page sits open, the session gets new tokens. The form is then refused (419, or TokenRejected), and what was typed is lost.

pf-utils.js prevents that on its own. Every page's head names where the current tokens are, in <meta name="csrf-refresh" content="…/Csrf">. That includes an anonymous page, because a sign-in form sits open as long as any. Once a page's tokens are ten minutes old, the script asks for them again:

  • when the tab comes back into view;
  • before a form carrying a token is sent. The form is held, refreshed, and then sent once, and no other submit handler sees the first attempt.

The answer goes into every _csrf_token field, into <meta name="csrf">, and into every form-token field, renamed as well, because a form token's name is the session's token. window.pfRefreshCsrf() asks at once.

  • Never on a timer. A page that polled would keep an idle session alive for ever, which defeats its lifetime.
  • GET /Csrf (Pramnos\Application\Controllers\Csrf) is served by the framework, so the application adds nothing. It answers {"token", "form": {"name", "value", "value_ip"}} with Cache-Control: no-store.
  • Another site cannot read it. It sends no CORS headers, and it refuses a request the browser marks Sec-Fetch-Site: cross-site.
  • A refresh that fails sends the form as it was.
  • A session that ended still ends. The refreshed tokens belong to the new, signed-out session, so a form behind sign-in is still sent to sign in.

The form token

echo $session->getTokenField();          // in the form
if (!$session->checkToken('POST')) { … }  // in the action

CsrfMiddleware API

Method Description
new CsrfMiddleware(string $fieldName = '_csrf_token', array $exemptPaths = []) Constructor; a custom field name, and first path segments to let through in addition to unsubscribe
CsrfMiddleware::token(): string Returns the session CSRF token
CsrfMiddleware::tokenField(string $fieldName = '_csrf_token'): string Returns <input type="hidden"> with HTML-escaped token

Session Security

What the session does on its own

  1. Strict session ids. Session::start() sets session.use_strict_mode = 1 before session_start(), so PHP refuses a session id it did not generate.
  2. A new id at every privilege change. Session::regenerateId() calls session_regenerate_id(true), keeping the session's contents. The framework's login path calls it before writing the signed-in state, so an id planted beforehand is worthless; Session::reset() — the logout wipe — calls it too, along with new form and synchronizer tokens.
  3. Cookie flags. The session cookie is always HttpOnly and SameSite=Lax, and Secure whenever Session::isHttps() is true. These are set in Session::start() and are not configurable.

Signing somebody in or out from your own code

$session = \Pramnos\Http\Session::getInstance();

// Sign in: new id first, then write — everything lands on the new session.
$session->ensureStarted();
$session->regenerateId();
$session->set('userid', $user->userid);

// Sign out: clears the signed-in state, regenerates the id and both tokens.
$session->reset();

reset() sets logged to false and uid to the guest, so it is the last call of a logout and never follows a login. Prefer the framework's Auth login, which does all of this and records the sign-in.

Session::isHttps()

if (\Pramnos\Http\Session::isHttps()) {
    // set a Secure cookie, redirect HTTP→HTTPS, etc.
}

Accepts HTTPS set to 'on' (Apache/nginx) or '1' (IIS/CGI), and X-Forwarded-Proto from a trusted proxy — see trusted proxies.

The session cookie is strictly necessary and needs no consent. Anything optional, such as analytics, marketing pixels or remembered preferences, goes behind the framework's cookie banner. See the Cookie Consent Guide.

Password Security

Hashing

Always hash passwords before storing:

// DO NOT store plain passwords
$plainPassword = $_POST['password'];

// The framework's own call. Equivalent to password_hash($p, PASSWORD_DEFAULT),
// with one place to configure the cost.
$hashedPassword = \Pramnos\Auth\PasswordHash::make($plainPassword);

// Store $hashedPassword in database

PasswordHash::make() is what User, the database auth driver and the 2FA backup codes use. It is a thin wrapper over password_hash($plain, PASSWORD_DEFAULT) — same algorithm, same portable hash format, verified by the same password_verify() — that exists so the cost is configurable in one place instead of four.

The cost, and why you should leave it alone

On PHP 8.5 PASSWORD_DEFAULT is bcrypt at cost 12, about 143 ms per hash. That slowness is the feature: it is what makes an offline attack on a stolen hash expensive.

PRAMNOS_BCRYPT_COST overrides it. A production deployment should leave it unset.

cost  4:   0.71 ms
cost  8:   9.05 ms
cost 12: 142.9 ms   ← the default

The one environment where lowering it is right is a test suite, and the reason is instructive: enabling 2FA hashes ten backup codes, so a single call cost 1.4 s, and this framework's two TwoFactorAuthService integration classes spent 42 s between them inside bcrypt — testing replay protection and storage, none of which is a property of the cost. The framework's tests/bootstrap.php sets 4.

Anything outside bcrypt's range of 4–31, or not a number, is ignored and the default applies: a typo in an environment variable cannot weaken hashing, and cannot raise an error that stops people logging in either.

Password Verification

// Verify against stored hash
if (password_verify($plainPassword, $storedHash)) {
    // Password correct
} else {
    // Password incorrect
}

// Check if hash needs rehashing (algorithm updated)
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
    $newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
    // Update database with new hash
}

Account security: the switches, and what each one costs

Pramnos\Auth\SecurityPolicy is one place, declared in one block, and every switch is off by default:

// app/app.php
'auth' => [
    'security' => [
        'regenerate_session_on_login'          => true,
        'ip_rate_limit'                        => ['attempts' => 30, 'window' => 900],
        'notify_security_changes'              => true,
        'session_idle_timeout'                 => 3600,
        'session_absolute_timeout'             => 2592000,
        'revoke_sessions_on_password_change'   => true,
        'require_second_factor_from_usertype'  => 90,
        'password_history'                     => 5,
        'totp_replay_cache'                    => true,
        'human_check'                          => ['login' => true, 'register' => true],
        'require_factor_enrolment_from_usertype' => 80,
    ],
],

Off by default is the contract, not caution. This framework is shared by applications that did not ask for any of this, and several of these end sessions, refuse logins or send mail — changing that silently on an upgrade is an incident. It also means each one can be described as a decision with a price, which is the honest way to present a security control.

Switch What it stops What it costs
regenerate_session_on_login session fixation — an id valid before authentication stays valid after it sessions.sid is stale for the rest of that one request; an application keying its own state on the session id must know
ip_rate_limit one address trying one password against ten thousand usernames, which no per-account counter ever sees a shared office or campus NAT shares the counter
notify_security_changes a stolen session changing the address and then the password with the owner never hearing mail, per change, including the routine ones
session_idle_timeout a session left open on a shared screen somebody is signed out while reading
session_absolute_timeout a session that stays valid for a year because it is used daily everybody re-authenticates on a schedule
revoke_sessions_on_password_change the attacker keeping the account after the owner "fixes" it other devices are signed out, which reads as a fault if it was routine hygiene
require_second_factor_from_usertype an administrator with a password and nothing else a step-up the person did not choose; it resolves to a mailed code, so it cannot lock them out — and demands nothing while the installation has no mail to send it by
password_history "change it" meaning "type the same one again" somebody who wants their old password back cannot have it, and support cannot give it to them
totp_replay_cache one TOTP code completing two logins inside the same 30-second window needs a cache that can count atomically (Redis, memcached); without one the older guard applies and nothing is refused
human_check a script submitting the sign-in, registration or reset form thousands of times for free the visitor's battery, and a browser with no Web Worker or no crypto.subtle cannot submit the form at all
require_factor_enrolment_from_usertype an administrator satisfying the requirement above with a mailed code for ever every page redirects to the setup screen until they enrol; needs RequireFactorEnrolmentMiddleware registered

Three of them are worth expanding, because their shape matters more than their name.

The rate limit is per address and the lockout is per account, and they are not alternatives. loginlockoutsteps protects one account from being guessed at. It is no defence at all against the attack that actually happens — a list of leaked username/password pairs, one attempt each — because every per-account counter stays at 1. The address limit answers that half: N failures from one address in a window, then that address is refused for the rest of the window. Fixed, not a ladder, so a shared NAT is slowed rather than banned for a day.

Two details of recordFailedAttemptWithin() that are the difference between a limit and a denial of service:

  • The deadline belongs to the window the first failure opened, not to the latest attempt. Refusing until now + window on every failure would let a slow attacker hold an address refused for as long as they cared to keep typing — and the people that punishes are the ones sharing the address, not the attacker, who has a thousand others.
  • A threshold or window below 1 disables the limiter rather than tightening it. attempts >= 0 is true of every attempt, so a mistyped 0 would otherwise refuse the first request from every address on the site, for the length of the window, with nothing naming the setting.

The address limit is checked before the password is. That is the order, and it is the security property rather than an implementation detail: a limiter that verified the credentials first and then refused would still stop brute force and would also be a perfectly good oracle for credential stuffing — the attacker reads the difference between «locked» after a real password and «locked» after a wrong one, or simply the timing. So a limited address learns nothing at all, including whether the password it sent was right.

It is a second counter rather than a wider one, because the two answer different questions: the account lockout stops somebody guessing one password, and the address limit stops somebody trying one password against ten thousand accounts — which no per-account counter can see, since each account records exactly one failure. The configured window and threshold travel with each record, so an installation that sets them gets them; a limiter reading a default window would ignore the configuration it was given.

Notices go to the previous address too. That is the whole reason SecurityChangeNotifier exists rather than a line at each call site: a stolen session's first two moves are to change the address and then the password, and every notice after the first goes to the attacker. The mail to the old address is the only signal the owner gets.

Four details of that, because each is a way to lose the signal while appearing to send it:

  • The old address is mailed through a detached notifiable, not by re-pointing the user object. The user is a live model other code holds, and mutating its address to send one mail is the kind of change that leaks into a save().
  • It is skipped when it matches the current one case-insensitively and after trimming. An address that changed only in capitalisation has not changed, and two identical mails about one event teach the recipient to ignore both.
  • It is skipped when it is not an address. It arrives from whatever the account held before — a legacy row, an import, a column somebody once used for a note — and handing that to the mailer is a bounce at best, and on a transactional provider a reputation charge to the sender.
  • userid 0 and 1 are refused before a user is loaded. A notice «about» the guest or system row is a mail to whatever address those rows carry, reporting a change to an account nobody owns.

And the whole thing is best-effort: a send that fails is logged and swallowed. A notification is never worth failing the change it reports — somebody told «your password could not be updated» because a mail server was down will try again, and the second attempt, on an account whose password did change, is what actually goes wrong.

Timeouts are enforced in Session::staticIsLogged(), not in a middleware, because that function is what every "is somebody signed in" path goes through — the current user, the controllers' guard, the API's session exchange. A timeout in a middleware is a timeout the paths that skip the middleware do not have. A session with no recorded start is treated as starting now, so switching either limit on does not sign out every existing session at once — which is how a security setting gets switched straight back off.

The TOTP replay guard closes a window the old one could not see. last_used on the account stops a code being reused one request after another; it cannot stop two requests inside the same window, because both read the same timestamp and both conclude the code is fresh. That is not theoretical — it is a phished code replayed immediately, or a double-submitted form. Answering it needs a store both requests can see atomically, which is what Cache::increment() is; a count of 1 means this request claimed the code. When no counting cache is available the code is allowed on the older guard rather than refused, because a login that fails when Redis is down is worse than the window.

The two second-factor floors are one decision in two switches. require_second_factor_from_usertype makes a factor a condition of signing in. It cannot lock anybody out, and that is deliberate: an account above the floor with nothing enrolled is asked for a code by email, which every account can satisfy — enrolment happens after signing in, so refusing the mail would be a lockout by design.

That holds only while mail can be sent. With smtp_host empty the code would never arrive, so the floor demands nothing and the password is let through. This is the state of a fresh installation, where the first administrator is signing in to configure mail among everything else; the enrolment switch below is what stops that account at the setup screen until it holds a real factor. Set both, and an installation without mail is still not an installation whose administrators sign in with a password alone for long.

Which means that switch alone leaves an administrator holding nothing but a mailbox, and a mailed code is the weakest factor here: it is one mailbox compromise from being no factor at all, and the password reset arrives at the same address. require_factor_enrolment_from_usertype is the other half. Set it to the same number, and register the middleware:

'middleware' => [
    'Pramnos\Http\Middleware\RequireFactorEnrolmentMiddleware',
],

Then an account at or above that usertype has every page redirected to the second-factor setup screen until it holds an authenticator, a passkey, or an adaptor scoring at least FactorEnrolment::MIN_STRENGTH. The mailed code becomes the on-ramp rather than the destination.

Three things make it a wall rather than a trap:

  • The doors out stay open — the setup screens, the passkey endpoints, the sign-in flow, the account area, signing out, the API and the discovery documents. Every one of those is a lockout on its own if it closes.
  • It fails open. No session, no application, a store that will not read: the request goes through. Guessing the other way redirects every administrator in a loop, and the screen that would fix it is one of the ones being redirected.
  • There is a way back from a terminal. auth:twofactor-status --missing lists who the wall will stop before you set the switch; auth:twofactor-reset <user> clears an enrolment so somebody who lost their authenticator can enrol again — they sign in with a mailed code and meet the wall, which is the whole recovery path and needs no secret read out over a phone. It also forgets the account's trusted browsers, since one would go on skipping the factor enrolled next. It writes auth.twofactor_reset to the audit log, with the administrator given by --by=<userid>. It says what the next sign-in asks for by what the application offers: a mailed code only where there is one. Neither command ever prints a secret, a QR URI or a backup code.

Password history is compared with the login's own verifier. A previous password is stored exactly as users.password stored it and checked with PasswordHash::verify() — a second comparison written for this would be a second thing to get wrong. It costs one bcrypt verification per remembered hash, on a password change and nowhere else. And it fails open: with no table, nothing is refused, because the change is something somebody is doing for a reason.

What is deliberately not here: checking passwords against breach corpora. It needs an outbound call per password change to a third party, and that is a decision an application makes with its own privacy notice, not one a framework makes for it.

Secrets at rest: hash what you verify, encrypt what you use

Two kinds of secret live in the database, and they want opposite treatment.

A secret you only ever check — a user's password, a 2FA backup code — is hashed. Nothing ever needs the original back, so nothing should be able to get it back. PasswordHash::make() and password_verify() do this, and the password section above covers it.

A secret you have to use — an SMTP password, a webhook signing key, a TOTP seed — cannot be hashed, because the application needs the actual bytes to authenticate outbound, compute an HMAC, or derive a code. Those are encrypted with Pramnos\Security\Encrypter.

Reaching for encryption on the first kind is a downgrade, not extra safety: a reversible secret plus an APP_KEY that sits in .env on the same host gives an attacker back what a hash never would.

Using the Encrypter

use Pramnos\Security\Encrypter;

$stored = Encrypter::encrypt($apiToken);   // "enc:v1:…", safe for any text column
$token  = Encrypter::decrypt($stored);     // back to the original

// A column that may still hold values written before it was encrypted:
$token = Encrypter::maybeDecrypt($row['api_token']);

NaCl secretbox (XSalsa20-Poly1305) via libsodium, keyed from APP_KEY. It is authenticated, so a value altered in the column fails to open instead of decrypting to something plausible — decrypt() throws rather than guessing.

maybeDecrypt() is what makes adoption free. It returns anything without the enc:v1: marker unchanged, so a column can be read through it from the first deploy: old rows come back as they are, new rows come back decrypted, and the column converts itself as values are rewritten. No migration, no downtime.

Encrypter::isAvailable() reports whether APP_KEY is set, for a screen that would rather warn than fail.

What it protects, and what it does not

The key is in .env, on the same host as the application. So this defends against every way a database is read without the filesystem — a leaked backup, a dump handed to a contractor, SQL injection in an unrelated endpoint, a hosting neighbour, a DBA reading rows they should not. It does not defend against an attacker who owns the host: they read .env and decrypt at leisure.

Worth having. Worth not overstating — "encrypted at rest" in a compliance answer means the first list, never the second.

Rotating APP_KEY

Everything encrypted under the old key becomes unreadable, loudly: decrypt() throws rather than returning nonsense. Re-encrypt before rotating, or accept that those credentials have to be entered again.

What the framework already encrypts

Three credentials the framework stores on your behalf are encrypted with no work from you, and all three are recoverable secrets rather than verifiable ones:

Value Where Why it cannot be hashed
smtp_pass settings SMTP AUTH needs the password
Webhook signing secret oauth2_webhook_endpoints.secret_key it is the HMAC key each delivery is signed with
TOTP seed user_twofactor.secret, twofactor_setup.temp_secret every code is derived from it
Realtime channel key applications.broadcast_secret it signs channel authorizations
Access/refresh tokens, auth codes usertokens.token an administrator screen offers them for copying

And two that are hashed, because the server only ever verifies them:

Value Where
OAuth2 client secret applications.apisecret
2FA backup codes user_twofactor.backup_codes

The client secret is shown once, when it is created or rotated, and cannot be read back afterwards — that is what hashing buys.

Tokens are encrypted rather than hashed, on purpose

A token is verified and never re-read, so hashing is the reflex — and it is what a usertokens.token column would get if nothing else were true. Something else is: administrators reproduce a failing integration by copying a token into curl, and that is a real tool, not a leftover.

So the column is split. token_lookup holds sha256(token) and is what all fifteen authentication lookups match on; token holds the value encrypted, and Token::reveal() is the only way back to it.

$plain = (new Token())->reveal($row['token']);   // '' when APP_KEY cannot open it

Two consequences worth stating.

The digest is unkeyed. A keyed HMAC would be right for a secret somebody could guess; every value here is 256 bits from random_bytes() or a signed JWT, so there is no dictionary to attack. Keying it would instead make APP_KEY load-bearing for authentication — rotating the key would sign everybody out. Unkeyed, a rotation costs the ability to reveal a token and leaves authentication working.

reveal() does not make handing a token out safe. Whoever receives one can act as its owner, and the resulting requests are indistinguishable from the owner's — the action log will record them as theirs. Offer it where an administrator genuinely needs it, and log the fact if that matters to you. A row written before hashing still holds a plaintext secret and converts itself the first time that client authenticates successfully, so there is no migration to run and no window where a client cannot connect.

Every lookup matches token_lookup, and there is a test that says so.

->where('token_lookup', \Pramnos\User\Token::lookup($presented))   // right
->where('token', $presented)                                        // matches nothing

Comparing token cannot match a presented value any more, and every caller in this framework reads "no row" as "not a valid token" — so a missed lookup fails closed, silently, on a path that worked the day before. Watch the aliased form, where('ut.token', …), which a grep for where('token' does not see: in token introspection it answers {"active": false} for every token, and in the OAuth2 middleware it refuses every Bearer request.

TokenAtRestTest::testNoLookupMatchesOnTheTokenColumn() reads src/ and fails on any surviving comparison, alias or not. Add a sixteenth lookup and it will tell you.

An application with lookups of its own on token. The framework's OAuth code needs token_lookup, which add_token_lookup_to_usertokens adds. Whether an application's own WHERE token = … keeps matching depends on whether tokens are stored encrypted, which is a setting:

// app/app.php
'auth' => ['encrypt_tokens' => false],   // default true, where there is an APP_KEY
  • Encrypted (an APP_KEY, and the setting not false): every token the framework writes is stored encrypted, and the migration encrypts the live rows it fills. A lookup on token misses them.
  • Not encrypted (no key, or encrypt_tokens false): the migration adds and fills the digest in one statement and leaves every value as it is. New tokens are written the same way, and lookups on token keep working.

Token::encryptsAtRest() answers which. So the order is:

  1. Set 'encrypt_tokens' => false and run the migration.
  2. Change the application's own lookups to ->where('token_lookup', Token::lookup($presented)).
  3. Remove the setting. Tokens written from then on are encrypted. Rows written before stay plaintext until they are written again.

The lookup index is unique where it can be. The same token stored twice gives the same digest twice. The migration then makes idx_usertokens_token_lookup a plain index rather than fail the batch, and logs how many values are duplicated, with the query that finds them. Keep one row of each, then drop the index and add it as UNIQUE.

Adding the column yourself works too, and the migration then adds only the index. It fills only rows whose token_lookup is empty, so rows you filled stay as they are: plaintext, until each is next written with a key. Fill the column the way the migration does, sha256 of the value in lowercase hex, or the framework finds nothing.

Both columns come from one place, and saving a token goes through it. Token::storageFor($value) produces the pair — the value (encrypted where tokens are) and the digest — and Token::save() and User::addToken() both go through it, so a token written either way is stored encrypted and can be found by every lookup.

$token = new \Pramnos\User\Token();
$token->userid = $userid;
$token->token  = $plainValue;     // plaintext in, encrypted + digested on save
$token->save();

Two properties of that, both deliberate:

  • Only a plaintext value is converted. fillProperties() copies the row as it is, so a loaded token holds ciphertext. Re-encrypting that would store something nothing can decrypt and a digest of the ciphertext — an authentication outage, not a bug — so an already-encrypted value is written back untouched and its existing digest left alone.
  • $this->token becomes the stored value after a save. So a second save() does not encrypt the plaintext again under a fresh nonce, rewriting the column for no change, and the object matches what a load() would give.

Loading by value means loading by digest. Token::load($value) with a non-numeric value looks up token_lookup, because encryption is non-deterministic and the stored ciphertext never equals the presented value. It falls back to a plaintext comparison only when that finds nothing, for rows written before the column existed.

Both paths check that the column exists first, cached once per process. The lookup column arrives with a migration, and an installation that has not run it yet would otherwise get Unknown column 'token_lookup' on the save path — a token save turning into a failed request on a database that is merely out of date. hasColumn() issues a query, and save() runs on the authentication path whenever a token's last-used moment is persisted, so asking once per process is the difference between a guard and a tax.

A unit test with a mocked query builder cannot catch a wrong lookup column: the mock returns the prepared row whatever the WHERE says. That is why the guard is a test that reads src/.

Nothing about using these changes — getSecret() returns a base32 seed, a delivery signs with the real key, getSetting('smtp_pass') returns a password. Only the rows are different, and rows written before the change keep working until something rewrites them.

Settings that are encrypted automatically

Settings encrypts the values it knows are credentials, listed in Settings::ENCRYPTED_SETTINGS: smtp_pass and database_readonly_dsn.

Settings::getSetting('smtp_pass') returns the plaintext and setSetting() takes a plaintext; only the row is encrypted. A row written in plaintext is read as it is and converts itself the next time it is saved, so there is no migration.

Without an APP_KEY the two differ, on purpose:

  • smtp_pass is stored in plaintext, because an installation must still be able to save its mail settings, and an unreadable credential is worse than the exposure.
  • database_readonly_dsn is refused (Settings::KEY_REQUIRED_SETTINGS). It is optional — db-inspect uses the ordinary connection without it — so storing a database password in the clear would buy nothing. Run key:generate first.

XSS Prevention

View Escaping Helpers (v1.2)

A global e() function and View::escape() / View::e() instance methods wrap htmlspecialchars() with the safest flags.

Flags used: ENT_QUOTES | ENT_SUBSTITUTE — escapes both single and double quotes; replaces invalid UTF-8 with U+FFFD.

<!-- Short form — most common -->
<h1><?php echo e($model->title); ?></h1>
<p><?php echo e($model->description); ?></p>
<input name="q" value="<?php echo e($request->get('q')); ?>">

<!-- Via $this in a View template -->
<h1><?php echo $this->e($model->title); ?></h1>
<a href="<?php echo $this->escape($model->url); ?>"><?php echo e($model->label); ?></a>

<!-- Trusted HTML — no escaping needed -->
<?php echo $doc->getContent(); ?>

Escaping Table

Input Output
<script>alert(1)</script> &lt;script&gt;alert(1)&lt;/script&gt;
" onclick="alert(1) &quot; onclick=&quot;alert(1)
it's fine it&#039;s fine
AT&T AT&amp;T
null / false '' (empty string)
42 / 3.14 '42' / '3.14'

API Reference

Symbol Description
e(mixed $value, string $encoding = 'UTF-8'): string Global function — HTML-escape a value
View::escape(mixed $value, string $encoding = 'UTF-8'): string Instance method — delegates to e()
View::e(mixed $value, string $encoding = 'UTF-8'): string Short alias for escape()

Context-Aware Escaping

e() is for HTML character escaping only. For other contexts:

// JavaScript context
$escaped = json_encode($text);

// URL context
$escaped = urlencode($text);

// CSS context — whitelist approach
$escaped = preg_replace('![^a-z0-9-]!i', '', $text);

Note: e() does not filter javascript: URIs. Validate and whitelist URLs at the application level, or use CSP.

SQL Injection Prevention

Use Parameterized Queries

// UNSAFE — never do this
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
$result = $db->query($sql);

// SAFE — use QueryBuilder
$user = $db->queryBuilder()
    ->from('users')
    ->where('email', $_POST['email'])  // Automatically parameterized
    ->first();

// SAFE — use prepareQuery with printf-style
$sql = $db->prepareQuery("SELECT * FROM users WHERE email = %s", $_POST['email']);
$result = $db->query($sql);

QueryBuilder Escaping

The QueryBuilder automatically handles escaping:

$users = $db->queryBuilder()
    ->from('users')
    ->where('username', 'LIKE', '%' . $search . '%')  // Auto-escaped
    ->get();

Authentication

Login/Logout

$auth = \Pramnos\Auth\Auth::getInstance();

// Login — verifies the password, regenerates the session id, writes the signed-in
// state and records the sign-in. Returns true or false.
if ($auth->auth($username, $password, remember: false)) {
    $user = \Pramnos\User\User::getCurrentUser();
} else {
    // Authentication failed
}

// Logout — ends the session record, clears the cookies and calls Session::reset().
$auth->logout();

The bundled sign-in screens go through LoginFlow, which adds the lockout, the address limit, the human check and the second factor on top of this.

Login Lockout

Pramnos\Auth\Loginlockout counts failures per scope ('user', 'identifier' or 'ip') and identifier, and locks progressively:

$lockout    = new \Pramnos\Auth\Loginlockout();
$identifier = strtolower(trim($username));

$status = $lockout->getLockoutStatus('identifier', $identifier);   // ['locked' => bool, 'remaining' => seconds]
if ($status['locked']) {
    return "Too many login attempts. Try again in {$status['remaining']} seconds";
}

if (!$auth->auth($username, $password)) {
    $lockout->recordFailedAttempt('identifier', $identifier);
} else {
    $lockout->clearSuccessfulLoginState('identifier', $identifier);
}

The ladder and window come from the loginlockoutsteps and loginlockoutwindowseconds settings — see the Authentication guide.

A lockout whose table cannot be reached fails open. The table may not be migrated yet, or the database may be down. Then the status reads "not locked", a failure is not recorded, and the problem is logged once per request. A lockout is a second line against guessing, and refusing every sign-in because it is missing would turn a deployment mistake into an outage for everybody. 'auth' => ['security' => ['lockout_fails_closed' => true]] makes it refuse instead.

Two-Factor Authentication

TOTPHelper is static and holds no user; the secret is stored per account by TwoFactorAuthService, which also handles backup codes and the replay guard.

use Pramnos\Auth\TOTPHelper;

$secret = TOTPHelper::generateSecret();                       // base32
$uri    = TOTPHelper::buildOtpAuthUri($secret, $email, 'My App');

if (TOTPHelper::verifyCode($secret, $code)) {
    // Code valid (current window ± the accepted drift)
}

See Two-Factor Authentication for the full enrolment flow.

Content Security Policy

The framework sends a Content-Security-Policy header on every page it renders. You do not build it: it comes from Application::cspPolicy(), starting from default-src 'none', with a per-request nonce for inline scripts and styles.

Allow another origin in app/app.php, under csp:

'csp' => [
    'script-src'  => ['https://cdn.jsdelivr.net'],
    'connect-src' => ['https://api.example.com'],
],

The directives that take sources, their defaults and what is fixed are in the Framework guide. A key outside that list is logged and ignored.

Inline scripts in a template rendered by the framework get the nonce stamped on them automatically. Where you write markup the renderer does not see — a string you send yourself — read the nonce from the application:

$nonce = \Pramnos\Application\Application::currentInstance()?->cspNonce ?? '';
echo '<script nonce="' . htmlspecialchars($nonce, ENT_QUOTES) . '">…</script>';

Do not also set a Content-Security-Policy header yourself, in PHP or in the web server: a browser enforces every policy it receives, so a second one can only make the first stricter, and the result is scripts blocked for reasons neither policy states.

File Upload Security

Validate Uploads

if ($_FILES['avatar']['size'] > 5 * 1024 * 1024) {
    throw new \RuntimeException('File too large');
}

$allowed = ['jpg', 'png', 'gif'];
$ext = pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION);

if (!in_array(strtolower($ext), $allowed)) {
    throw new \RuntimeException('File type not allowed');
}

// Verify MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['avatar']['tmp_name']);

if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    throw new \RuntimeException('Invalid file type');
}

// Move to secure location
move_uploaded_file($_FILES['avatar']['tmp_name'], '/secure/uploads/avatar_' . uniqid() . '.jpg');

Who is the client? Trusted proxies

$_SERVER['REMOTE_ADDR'] is the connecting peer. Behind a reverse proxy, a CDN or a load balancer that peer is the proxy, so every visitor in the world shares one address. A per-IP rate limit becomes a global one and fires for everybody at once; anything binding a session to the address binds every session to the same value.

Use Request::clientIp() rather than reading REMOTE_ADDR directly:

$ip = \Pramnos\Http\Request::clientIp();

It also decides whether the request was HTTPS

Session::isHttps() answers for the connection the browser made, not the one PHP received. Behind a proxy that terminates TLS those differ: $_SERVER['HTTPS'] is empty on the plaintext hop, and the session cookie was issued without its secure flag — so it then travelled on any http:// request to the domain.

X-Forwarded-Proto is consulted, but only when the peer is in trusted_proxies. The header is client-supplied, so believing it unconditionally would let any visitor assert https and be handed a secure cookie over a plaintext connection. With no proxies declared the answer is $_SERVER['HTTPS'] alone, exactly as before.

One more reason the list is worth configuring even when per-IP rate limiting is not in play.

Configure the proxies, or nothing changes

// app.php
'trusted_proxies' => ['private_ranges'],                      // shorthand
'trusted_proxies' => ['cloudflare'],                          // shorthand
'trusted_proxies' => ['10.0.0.0/8', '2001:db8::/32', '192.0.2.7'],

With the list empty — the default — the answer is REMOTE_ADDR, unchanged.

This is not an oversight, it is the point. Reading X-Forwarded-For unconditionally would be worse than doing nothing: the header is written by the client, so an attacker setting a fresh random value on every request gets a fresh rate-limit bucket every time and defeats the limiter completely — while the logs show a healthy spread of addresses and the limiter reports that it is working.

So a forwarding header is believed only when the peer that delivered it is itself a trusted proxy, and the chain is walked from the right — the end the infrastructure appended — taking the first address that is not a trusted hop. The leftmost entry is the client-supplied end and is never trusted.

Forwarded (RFC 7239) and CF-Connecting-IP are understood under the same rule. X-Real-IP is deliberately ignored: it is single-valued, so there is no chain to walk.

Behind Cloudflare?

Set 'trusted_proxies' => ['cloudflare']. Until you do, session and token records store the Cloudflare edge address rather than the visitor's. ClientIpResolver::CLOUDFLARE_RANGES is a snapshot of the published list and does change — pin your own copy if this matters to you.

Rate limiting a route: RateLimitMiddleware

use Pramnos\Http\Middleware\RateLimitMiddleware;

$router->addGlobalMiddleware(new RateLimitMiddleware(120, 60));          // 120 a minute
$router->post('/api/export', $handler)
       ->middleware(new RateLimitMiddleware(5, 60, 'export:'));          // its own bucket

It works on any cache adapter. With an atomic counter (Redis, Memcached) the limit is a fixed window counted on the server, which stays exact under concurrency. Without one (Array, File) it is a sliding window built from load-modify-save, which undercounts a concurrent burst. A limit doing security work wants Redis. Over the limit, the middleware throws TooManyRequestsException, rendered as 429 with Retry-After.

$keyPrefix separates one limiter's buckets from another's. It does not change what is counted.

Counting a key or a user instead of an address

By default the bucket is the client address from Request::clientIp() — so configure trusted proxies first (above), or every visitor behind the proxy shares one bucket. An address is the wrong unit for an API: two keys behind one NAT share a budget, and one key used from many addresses has none. identify names what to count:

new RateLimitMiddleware(
    maxRequests: 600,
    perSeconds: 3600,
    keyPrefix: 'ratelimit:api:',
    identify: static fn (Request $r): ?string => ($_SERVER['HTTP_X_API_KEY'] ?? '') ?: null,
);
  • A string is the identity. It is hashed into the bucket name and kept apart from address buckets, so a key spelled like an address cannot spend that address's allowance.
  • null or '' falls back to the address, so a request without a key is still limited.
  • [$identity, $maxRequests, $perSeconds] counts the identity against its own limit. That is how a key's tier becomes part of what it buys, without one middleware per tier:
identify: static function (Request $r): ?array {
    $key = ApiKeys::find($_SERVER['HTTP_X_API_KEY'] ?? '');   // your lookup
    return $key ? ["key:{$key->id}", $key->tierLimit, 3600] : null;
},

Any other answer throws UnexpectedValueException. A limiter that fell back to the address on a typo would look configured while counting the wrong thing.

Look the key up — do not count the header verbatim. An unverified header is client-supplied, so a fresh random value on every request gets a fresh bucket every time.

Human checks on public writes

\Pramnos\Security\HumanCheck is proof-of-work, not a CAPTCHA:

$check     = new HumanCheck(difficultyMs: 300);
$challenge = $check->challenge();     // hand to the page

if (!$check->verify($submitted['challenge'], $submitted['solution'])) {
    // refuse
}

Pair it with scaffolding/assets/js/pf-humancheck.js, which solves the challenge in a Web Worker while the visitor types.

Read the limit before adopting it. Proof-of-work does not stop automated submissions — it prices them. An attacker with a botnet and free CPU still gets through; what changes is that a thousand signups cost real compute instead of nothing. It is the right defence against volume and no defence at all against a targeted attack. Code reading a passed check must not conclude that a human was involved, because nothing here establishes that.

It is single-use (enforced through an atomic counter — a replayed solve is the obvious bypass), HMAC-signed with its own expiry, and costs the visitor battery, which is why difficulty is set in milliseconds of work on a mid-range phone and per call site.

On the bundled auth forms

Account's sign-in, registration and password-reset actions carry the check already — switched off, like every other account-security switch:

'auth' => ['security' => ['human_check' => true]],                  // all three forms
'auth' => ['security' => ['human_check' => ['register' => true]]],  // or name them

The form names are login, register and forgot. true means all three; an array names them one at a time and anything absent stays off.

Three details decide whether this is safe to switch on:

  • The check runs immediately after the CSRF check and before the credentials are read, so a refused submission costs no password verification and no mail.
  • It fails closed. A submission with no challenge, or with one that does not verify, is refused — a missing solution is the normal shape of an automated post. Minting, on the other hand, fails open: if a challenge cannot be created the page still renders, and the verification then refuses the submission, which is the same answer arrived at from the other end.
  • Any browser with JavaScript can solve it. There are four paths and they all produce the same answer:
Hash Where
1 crypto.subtle a Web Worker
2 this framework's own SHA-256 a Web Worker
3 crypto.subtle the main thread
4 this framework's own SHA-256 the main thread, in slices

Paths 2 and 4 exist because crypto.subtle is only available in a secure context — HTTPS, or localhost. A site reached over plain HTTP by hostname or LAN address (a staging box, a colleague's machine, a tablet on the office network) has none of it. Without these paths the check could not be solved there at all: the form would submit an empty solution, the server would refuse it, and the visitor would read "your browser must support JavaScript" while using a browser that supports it perfectly well.

Hashing in JavaScript is slower for the same difficulty, which is the right trade — a slow sign-in beats a sign-in that cannot happen. On the main thread the search is sliced with setTimeout so the tab keeps responding.

What is left over is a browser with JavaScript switched off, which submits an empty solution and is refused. That one cannot be otherwise: the check is the JavaScript, and letting a request through because it claimed to have none would make the check bypassable by saying so.

The signing key needs no setting up. securitySalt is used when the installation has one; otherwise the class generates 32 random bytes on first use and keeps them in the humancheck_secret setting. It does not write securitySalt itself — that value salts stored passwords, and filling it in would change how every existing password verifies.

The worker wants blob: in worker-src, which the framework's default policy allows. pf-humancheck.js builds its solver from a Blob rather than a published file, so adopting the check is one script tag. Under worker-src 'self' alone the browser refuses the worker — which is no longer fatal: the client falls back to solving on the main thread. It is slower, so a project writing its own policy should still allow it.

The client and the server agree, and that is a test

They have to match byte for byte: the payload with the signature stripped, : between payload and candidate, SHA-256, leading-zero bits, and base-36 for the candidate. Any one of those being wrong looks identical from production — a refused login, blamed on the browser.

So it is not a claim. HumanCheckClientAgreementTest loads scaffolding/assets/js/pf-humancheck.js — the exact bytes a browser is served — under Node, runs it down all four paths with the capabilities of each withheld the way an old browser withholds them, and verifies every answer with HumanCheck::verify(). The pure-JS SHA-256 is compared against a real one over empty input, ASCII, a long string, Greek text and an emoji (surrogate pairs and multi-byte UTF-8, which is where a hand-written encoder goes wrong).

Change the separator in that file by one character and five of its six tests fail. Without Node on the machine the test skips, loudly — run it somewhere with Node before shipping a change to that file.

Do not reimplement the hash to test it. A PHP copy of the algorithm agrees with itself and proves nothing; the only useful assertion is the one that makes the shipped client do the job.

The view side is one line, humanCheckField():

<form method="post" action="…">
    <?php echo \Pramnos\Http\Session::getInstance()->getTokenField(); ?>
    <?php echo humanCheckField($this->humanCheck ?? null); ?>

It returns an empty string when the check is off for that form, so the line is safe on every one of them, and otherwise emits the two hidden fields, marks the enclosing form for pf-humancheck.js, and carries the CSP nonce on the script it needs.

A function rather than a partial, because a partial lives in a view directory and a view directory is per-application: the sign-in page is the one screen no project inherits, so every project would have to copy the partial in to use the feature and then own a copy of the framework's markup for ever.

The nonce is not optional. A project with a strict Content-Security-Policy drops an un-nonced inline script silently — no error a person sees — so no solution is ever computed and the check then refuses every submission. That is a public form nobody can send, presenting as the check doing its job. Both script tags carry it: the inline one that hands the challenge to the form, and the src one that loads the worker.

The field id is derived from the token, so two forms with a check on one page — a sign-in beside a registration — do not collide. Without that the script would find the first form's input from both, and the second would submit an empty solution.

The challenge is encoded with JSON_HEX_TAG, because it lands inside a <script> element: </script> closes a script tag wherever it appears in one, since HTML looks for those characters and does not know it is inside a JavaScript string. Quoting is no defence there. The real token is hex.int.int.hmac and cannot contain a <, so nothing reachable depended on it — but this is a global helper with an array parameter, and a view may call it with its own.

When the human check itself breaks

The two halves of it fail in opposite directions, on purpose, and the combination is worth knowing before an incident rather than during one.

What broke What happens Why that way round
Minting a challenge (challenge() raises) the form renders without one an exception here would take the sign-in page down, which is a worse outage than a check nobody can solve
Verifying a submission (verify() raises) the submission is refused a check that accepted when verification broke would be bypassable by breaking verification
A submission with no challenge or no solution refused, without asking the service otherwise omitting the fields — the first thing anything automating a form does — would be enough to skip the check
The form is not gated by human_check passes, and mints nothing the early return has to come before the service is touched, or turning the feature off would still break when the service does

Put together: with the service down, the form renders and every submission is refused. The page is up and nobody can sign in. That is fail-closed, which is the defensible choice for a security control, but it is not what "the page renders without one" on its own suggests — so if you enable human_check on the login form, the check is on the critical path for signing in and its availability is your sign-in availability.

Two consequences for an operator:

  • Enable it on register and forgot before login. Those are the forms the check is really for — a thousand free registrations or reset mails — and neither is on the path of somebody who already has an account.
  • A sign-in outage with Δεν ολοκληρώθηκε ο έλεγχος ασφαλείας on the page is this, not a credential problem. The log line is HumanCheck challenge failed for login: … or HumanCheck verification failed for login: …, in the auth channel.

Forced second-factor enrolment fails open

RequireFactorEnrolmentMiddleware walks a privileged account to the setup screen until it has enrolled a second factor. Its decision has five ways of saying "not this request", and one of them is worth knowing before you rely on the wall:

Condition Answer
require_factor_enrolment_from_usertype is 0 not gated — the feature is off by default, so upgrading the framework cannot lock an installation out
nobody is signed in not gated — there is nothing to enrol, and the sign-in flow must not sit behind a screen that needs signing in
the path is on the allow-list not gated, without consulting the enrolment service
there is no current user object not gated
the enrolment service says so gated: redirect, and $_SESSION['factor_enrolment_required'] is set for the screen to explain itself
the decision raises not gated, and a line in the auth log

That last row is deliberate and it is the opposite of the human check on the sign-in form, which fails closed. The difference is what each failure costs. A broken human check refuses new submissions; a broken enrolment check would redirect every page of the site to a setup screen, and if the allow-list were ever wrong, the setup screen too. Locking an installation out of itself because a lookup failed is worse than a privileged account going one more request without a second factor.

So: this wall is not a containment boundary. It gets accounts enrolled; it is not something to rely on for keeping an unenrolled administrator out of a screen. If a screen must refuse an account without a second factor, check that on the screen.

The allow-list short-circuiting before the service is consulted matters for more than speed: the setup screen is itself a gated path, and a decision that ran first would send it to itself for ever. Paths are compared on the first segment, not as a substring — logo.png is not logout.

A TOTP code is single-use only if you ask for it, and only best-effort

A six-digit code verifies for its 30-second window plus the drift accepted either side — about ninety seconds — and within that window it verifies every time it is presented. Anybody who sees one submission can replay it: a proxy, a shared screen, a phishing page that forwards what it was given.

TwoFactorAuthService makes the first presentation the only one, by claiming the code in a counting cache. Two things to know before relying on it:

It is off unless you turn it on.

'auth' => ['security' => ['totp_replay_cache' => true]],

Without that, claimCode() is never called and a code is replayable for its whole window.

With it on, it stands down rather than refusing. Three separate ways:

Condition Result
the cache has no atomic counter (a file cache, no cache at all) the claim is allowed
the counter answers false — the server is unreachable mid-request allowed
anything raises allowed, and a line in the auth log

Deliberate: refusing every second factor while Redis is down is a larger failure than a ninety-second replay window. But it means the protection is best-effort, which is not what "single-use" sounds like — so it needs a counting cache to be worth anything. Redis or Memcached; a file cache cannot count atomically, and a read-modify-write would lose claims exactly when it matters, since two requests reading 0 would both write 1 and both believe they were first.

Two details that are right and easy to get wrong if this is ever reimplemented: the key contains a hash of the code and never the code, so a cache dump or a Redis MONITOR does not hand out live second factors; and the claim carries the account id, because six digits and a 30-second window make two accounts producing the same code at the same moment unlikely but possible — and a key without the account would sign one of them out of their own login.

A URL is on your site only if the next character is a boundary

Anywhere the framework decides whether a URL belongs to this installation — a Referer it will turn into a link, a returnUrl it will redirect to — the check cannot be a plain prefix match:

// Wrong, and it is an open redirect.
if (str_starts_with($url, $base)) { … }

For a base of https://example.com, the URL https://example.com.evil.test/phish starts with it. An attacker registers a host whose name begins with yours, sends a signed-in administrator to a page that remembers where they came from, and that page renders the attacker's URL as its own «Back» link — an open redirect with your site's appearance vouching for it. Nothing in the markup looks wrong.

What follows the base has to be a boundary — /, ?, #, or the end of the string:

protected static function isOnThisSite(string $url, string $base): bool
{
    if ($url === '' || $base === '') {
        return false;
    }

    if ($url === $base) {
        return true;
    }

    if (!str_starts_with($url, $base)) {
        return false;
    }

    return in_array($url[strlen($base)] ?? '', ['/', '?', '#'], true);
}

A trailing slash alone is not enough, and the reason is worth knowing before you simplify it: /adminer?db=x is a page at /adminer with no slash after it. The same comparison is used to ask "is this URL inside that section", so a rule demanding a slash stops recognising a section addressed with a query string.

Two more rules go with it, both of which the DevPanel's Back button follows:

  • check on the way out as well as on the way in. A value kept in the session outlives the request that validated it — a changed site URL, or a session restored from elsewhere, leaves something in there that was once ours and no longer is.
  • escape it. It arrived in a header and it is going into an href.

A path is the other shape a return address takes, and it has its own traps: //host/x is protocol-relative, /\host/x is read the same way by browsers, and javascript:…, data:… or /javascript:… carry a scheme. DevPanelController::isReturnable($url, $base) accepts a URL on this site or a path with exactly one leading slash and no scheme, and refuses the rest. The sign-in screens' ?return= goes through it (Account::sanitizeReturnUrl(), which first makes a relative path such as account/security absolute under sURL), as does every redirect after login, two-factor, passkey, registration or a new-device link.

The scaffolded sign-in and two-step forms put ?return= in their own action as well as in a hidden field, so the address bar after a failed attempt still names the destination: a refresh, a restored tab or a bookmark signs in to where the user was going, not to the dashboard.

Fetching a URL somebody else chose

A URL a visitor typed is not a URL the server may request, and the gap between those two is where server-side request forgery lives. The server sits inside a network the visitor cannot reach: a cloud provider's metadata endpoint on 169.254.169.254 serving credentials to anything that asks, an unauthenticated admin panel on loopback, the database on a private address, a neighbour service that trusts whatever arrives from inside the subnet. file_get_contents($url) on a caller-supplied address turns the application into a proxy into all of that, and the response usually goes straight back to whoever supplied the address.

$reason = null;
$status = 0;
$body = \Pramnos\Security\OutboundUrl::fetch($url, 5 * 1024 * 1024, $reason, 10, 0, $status);

if ($body === false) {
    // $reason says why. It never names the resolved address.
}

if ($status < 200 || $status > 299) {
    // A body arrived, but it is not the thing you asked for.
}

When the answer is at the top of the file

The cap is a refusal by default: a body larger than $maxBytes returns false, because half a JPEG or half a JSON document is the shape the caller expects and the failure surfaces somewhere else.

That is wrong for the things people actually cap a fetch for. Meta tags, a feed's header and a manifest are at the top of the file by construction, so throwing away the first 512 KB throws away the answer to keep the question. A caller reading an OpenGraph card capped at 512 KB — generous for a <head> — met a 783 KB WordPress front page and reported "That page could not be read", with the og:title sitting at byte 4,000.

$truncated = null;

$body = \Pramnos\Security\OutboundUrl::fetch(
    $url, 512 * 1024, $reason, 10, 3, $status,
    allowTruncated: true, truncated: $truncated
);

if ($truncated) {
    // Everything up to the cap, exactly. Fine for a <head>; not fine for a checksum.
}

It returns exactly $maxBytes, not the ceiling plus the rest of the 8 KB block it was noticed in — so a cap sized against a limit you have to respect is the number you get.

A cap exists so a hostile or careless server cannot fill memory. It does not exist to make a large page unreadable, and leaving it off is still right for anything that has to parse.

Check the status, not only the bytes. ignore_errors => true is deliberate — a caller that wants to read a 404's body should be able to — so a body arriving is not the same as a request succeeding. «Check the content» answers this for most bodies and fails on the case that matters: a CDN answering 404 with a placeholder image returns bytes that are a valid PNG, so every content check passes and somebody's grey «image not found» square is stored as the thing that was requested. Which is worse than storing nothing — nothing is visible as a gap, and a placeholder looks like a result.

The status is also the only place the difference between this address is permanently wrong (404, 410) and this server had a bad minute (a timeout) lives, and that difference is what decides whether you forget an address or retry it tomorrow.

fetch() is the thing to reach for. isPublic() is available on its own for a URL you are going to store rather than request now — a webhook target, a feed address in a settings screen — but prefer the combined call for an actual fetch, and the next section says why.

The check and the request have to be the same operation

Between an isPublic() that passed and a file_get_contents() that follows sits a second DNS lookup. A hostile resolver answers with a public address the first time and 127.0.0.1 the second, and the whole check was theatre. So fetch() dials the address it approved and puts the name in Host:, which makes «what did we check» and «what did we connect to» the same answer. The certificate is still verified against the name, via peer_name and SNI.

Redirects are followed only when asked, and every hop is checked

A 302 is a second URL, chosen by the server being fetched. The stream wrapper follows it without asking anybody, so an address that passed the check redirects to the metadata endpoint and the fetch proceeds — which is not hypothetical: an importer that checked its catalogue's host once and left the wrapper's following on was measured taking exactly that hop, with the second address receiving the connection.

$body = \Pramnos\Security\OutboundUrl::fetch($url, $maxBytes, $reason, 10, maxRedirects: 3);

0 (the default) follows none. Above that, each Location is resolved against the address that sent it and passed through isPublic() before it is dialled, and a refusal on any hop fails the whole fetch with a $reason saying which.

A redirect that is not followed is a failure, not an empty success. ignore_errors => true is what lets a caller read a 404 body, and it also meant a 302 came back as a successful fetch of an empty string — with no status and no Location anywhere in the return, so a caller could neither act on it nor know it had happened.

Refusing redirects outright is usually not open to you, which is why this is not simply off. An address that has sat in a catalogue for years is very often an http:// that now redirects to https://, or a path a CDN has since moved; refusing those is safe and useless.

Driving your own loop

If you need to do something between hops — count them differently, log each one, stop at a host you recognise — the decision is available on its own:

$hop = \Pramnos\Security\OutboundUrl::nextHop($currentUrl, $responseHeaders, $reason);
// string → the next checked address · null → not a redirect · false → refused, $reason says why

null and false are different answers and a loop turns on the difference: null means «this is the response», false means «stop, and do not use what you have». A falsy check collapses them and turns every ordinary 200 into a failure.

statusOf($responseHeaders) is there for the same reason, and has the same trap as the chain above: a chain the wrapper followed itself leaves several status lines in one block, and the one describing the response in hand is the last. Reading the first classifies the final 200 of a redirect chain as a redirect.

resolveLocation($from, $location) is the part worth not writing again. Four shapes arrive in the wild: an absolute URL; //host/path, which inherits the scheme and not the host — read as a path it turns somebody else's host into a directory on yours; /path; and a bare relative path, which is relative to the directory of the current path, so /a/b + c is /a/c and not /a/b/c. .. and . are collapsed, because the address that gets checked has to be the string that gets dialled.

What each guard is actually for

Guard The thing it stops
scheme allowlist (http, https) file:///etc/passwd and php://filter/…/resource=index.php are well-formed URLs. A denylist of «not file://» misses the second.
no credentials in the URL http://expected.example@10.0.0.1/ reads as a URL for expected.example — to a person skimming a log, and to any check that looks at the string instead of parse_url()'s host.
every resolved address, both families A name with one public and one private A record passes a check that looks at the first answer, then fails whichever one the fetch happens to connect to. A private-only AAAA passes an A-only check outright.
no records is a refusal A loop that checks each resolved address passes trivially when the list is empty, so «does not resolve» becomes «no address failed».
a byte cap, applied mid-stream A URL that answers with an endless stream otherwise costs the process its memory.
the reason never names an address The refusal message is logged and often shown. «That host resolves to an address inside this network» says enough; the address itself is the network map this check exists to keep.

What it does not do

It does not vouch for the content. Bytes fetched from a public address are still somebody else's bytes: read the type from the bytes rather than from the URL, keep the extension you store consistent with what you read, and do not put fetched markup — SVG, HTML — on your own origin, where its script is same-origin. MediaObject::addRemoteImage() is worth reading as a worked example: it uses fetch(), caps the body, reads the mime with finfo from the buffer, and refuses SVG for exactly that reason.

Helpers::fileGetContents() is a thin wrapper over this, and deprecated

There is a second way to fetch a URL in this framework, and it is not the one to reach for. \Pramnos\General\Helpers::fileGetContents() delegates to OutboundUrl::fetch() and carries an @deprecated tag pointing back at it.

Call OutboundUrl::fetch() directly in new code. The wrapper has to choose the byte ceiling, the timeout and the redirect budget on your behalf — they are Helpers::REMOTE_FETCH_MAX_BYTES, REMOTE_FETCH_TIMEOUT and REMOTE_FETCH_MAX_REDIRECTS — and it gives you neither the reason nor the status, which are the two things that tell you whether to retry an address or forget it.

Three properties of the wrapper that a caller has to know:

  • A host on a private address is refused, which is the point. Fetching sURL . $path against your own site fails, because that name resolves locally to a private address. Read the file from disk instead.
  • $fakeRef is ignored. Sending a borrowed referer and user agent is hotlink-protection evasion, and header injection does not belong on a checked fetch path. The parameter stays so the signature does not change.
  • allow_url_fopen is required. fetch() dials with a stream because it has to connect to the address it approved rather than to a name — that is the property the whole class exists for. Without the ini setting it fails closed; falling back to an unchecked curl fetch would undo the check.

Helpers::checkUrlStatus() is not guarded, deliberately

checkUrlStatus($url) reports ['online' => bool, 'status' => int] and does not go through any of this. It never disabled certificate verification and it follows no redirects, so it is not the same defect — but it will happily tell you whether something is listening on a private address, which makes it a host and port probe for anything that can influence its argument.

That is left as it is because «is this URL up» is a question people legitimately ask about internal addresses — a health check for a service on the same subnet is the ordinary use. So the guard cannot be moved inside it without breaking the honest callers.

If the URL comes from outside your application, check it first:

if (!\Pramnos\Security\OutboundUrl::isPublic($url, $reason)) {
    // refuse; $reason says why, without naming the address
}
$status = \Pramnos\General\Helpers::checkUrlStatus($url);

Note also that it buffers the whole response into memory to throw it away — CURLOPT_RETURNTRANSFER with no ceiling — so a URL that answers with an endless stream costs the process its memory even though the body is never read.

Personal data and the denial list

Diagnosing a production problem over SSH means a shell: everything the account can do, no record of what was read, and one mistyped DELETE away from an incident. The narrower thing is a single capability — db-inspect, an MCP tool that runs one read-only SELECT — and what makes it narrower is entirely what it refuses.

It runs on the stdio server by default, where a shell is already required. Putting it on the HTTP endpoint is one line and a deliberate one — the four steps are in the MCP guide. That endpoint is called the public one because it is reachable from off the machine, not because it is open: every call carries a validated bearer token and every tool declares a scope that token must hold.

It is one of eleven diagnostic readers that can be offered that way, split across three scopes: mcp:diagnostics for structure and state, mcp:logs for what has been happening, and mcp:db_read for the rows. Three rather than one because they disclose different things — and mcp:logs is the one without a boundary: log lines are free text, and the denial list below withholds columns, so it cannot see an address inside a stack trace or a token somebody logged in a URL. Granting it is equivalent to handing over the log directory.

Pramnos\Security\PersonalDataRegistry decides what comes back.

The query touches You get
ordinary tables rows, with personal-looking columns replaced by [withheld]
a personal table, projecting nothing but COUNT() the counts, in full
a personal table, anything else the column names, no rows

The second row is not a lesser answer for most questions. How many live tokens have no digest, are there duplicate settings names, how many images are under this size are all counts, and a count exposes nobody. Asking for the rows themselves is a different request with a different risk, and one somebody should make deliberately rather than discover they have made.

COUNT and not "an aggregate", deliberately. MIN(email) and MAX(email) return an address; SUM(salary) and AVG(salary) over a filter matching one row return that person's. A rule that accepted any aggregate would hand back exactly what the denial list withholds, behind syntax that reads as a summary.

And a count is still an oracle over a narrow filter: count(*) … WHERE email = '…' answers whether that person exists. That is not prevented, because preventing it means refusing WHERE token_lookup IS NULL, which is the query somebody actually needs. This stops rows arriving in bulk; it does not stop a determined question from somebody who already holds mcp:db_read on a production database.

Declaring your own tables

// app.php
'personal_data' => [
    'tables'  => ['customers', 'support_tickets'],
    'columns' => ['tax_number'],
    // Start from nothing instead of adding to the framework's lists. Rarely right:
    // the framework's entries are its own tables, and replacing them is an
    // application asserting it knows better about `usertokens`.
    'replace' => false,
],

A declaration is matched on the bare name, so it survives a table prefix and a schema qualifier: customers covers pramnos_customers and authserver.customers without anybody repeating the installation's own configuration.

create:migration asks whether each table it generates holds personal data and prints the line to paste — see the Console guide. That is the one moment somebody knows the answer.

Why a denial list, and what it costs

The framework's PublicRegistry argues the opposite case for tools, and correctly: filtering a shared list would mean every future tool is public until somebody remembers to exclude it.

Tables are not tools. An unknown tool is dangerous by definition; an unknown table is usually mundane, and an allow-list would mean nothing new can be looked at until a person adds it — friction on exactly the diagnosis the tool exists for.

So the cost of that choice is paid by the second half of the registry: column names are matched wherever they appear. A table nobody thought to list still has its email, password, token and phone withheld, matched on the whole name and on a _-separated head or tail — billing_email and user_phone are caught by declaring email and phone. It is a heuristic and it is stated as one: it reduces what leaks from a table nobody classified, and it is not a substitute for classifying it.

The framework declares its own tables (twenty-three of them) — credentials, identity, the GDPR record-keeping that exists precisely because these are personal, and tokenactions, because a request log keyed to a token is a record of what one person did and when. Those apply with nothing loaded and nothing configured.

The four boundaries, and which one is load-bearing

  1. The scope. db-inspect requires mcp:db_read, which no token carries unless somebody granted it — and which is not a grantable scope at all until an application registers the tool, so an installation that never offered it does not advertise it in scopes_supported either. It is checked when the tool list is built and again when the call arrives. It inherits mcp; system:admin deliberately does not inherit it, because administering the application and reading its production tables from another machine are two decisions and should be granted as two.
  2. A read-only database account, when the installation has configured one. Where it exists this is the real boundary, because PostgreSQL enforces it and we do not.
  3. Pramnos\Security\ReadOnlyQuery, which refuses anything that is not a read. Where there is no read-only account this is the only boundary there is.
  4. The registry above, which decides what the rows contain.

ReadOnlyQuery does not check the first keyword, because that check is defeated by a statement that is genuinely a SELECT:

WITH gone AS (DELETE FROM usertokens RETURNING *) SELECT count(*) FROM gone;

That begins with WITH, ends as a SELECT and empties a table. So the rule is the other way round: a statement is refused if a writing keyword appears anywhere outside a string literal or a comment. Literals, -- and /* … */ comments, dollar quoting and double-quoted identifiers are blanked — blanked rather than removed, so 'a' delete cannot collapse into adelete and slip past a word boundary.

It is a lexer, not a parser, and it errs towards refusal: a column genuinely called update_count is refused. That trade is deliberate — a false refusal costs a rephrased query, and a false acceptance costs somebody's data.

The read-only account, if you want one

Optional, and the framework does not require it. Setting one up is real work, and an installation may reasonably decide that somebody with development access to the box is already trusted — in which case the lexer above is the boundary and it is worth knowing that.

Where you do want the stronger version:

CREATE USER app_readonly WITH PASSWORD '…';
GRANT CONNECT ON DATABASE app TO app_readonly;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_readonly;

Then set one setting — user:password@host:port/database, the same shape the rest of the settings use:

Settings::setSetting('database_readonly_dsn', 'app_readonly:secret@localhost:5432/app');

It is stored encrypted, and refused outright without an APP_KEY — unlike smtp_pass, which is stored in the clear rather than lost, because an installation with no key must still be able to save its mail settings. This one is optional: nothing stops working without it, db-inspect uses the ordinary connection, so "plaintext rather than fail" would trade a database password for a convenience nobody asked to pay for. Run php pramnos key:generate first and the setting takes.

An account that is configured and unusable is an error, never a quiet fall back to the writable connection. That is the failure where somebody believes they have a boundary and does not, and it is invisible — which is worse than either of the two honest outcomes.

What is logged

Every call is written to mcpqueries with its statement, before the query runs and whether or not it succeeds — a record that only survives success is not a record of what was asked. Where the statement touched a declared-personal table, the log line says so.

Dependency Security

Keep Dependencies Updated

# Check for security vulnerabilities
composer audit

# Update dependencies
composer update

Composer also refuses, by default, to install a package version with a known advisory; --no-security-blocking overrides that and should stay off.

Security Headers

The framework does not send these for you (the CSP, which also covers framing through frame-ancestors, it does). Add them in a middleware that wraps the response:

use Pramnos\Http\MiddlewareInterface;
use Pramnos\Http\Request;
use Pramnos\Http\Response;

class SecurityHeadersMiddleware implements MiddlewareInterface
{
    public function handle(Request $request, callable $next): mixed
    {
        $response = $next($request);

        if ($response instanceof Response) {
            return $response
                ->withHeader('X-Content-Type-Options', 'nosniff')
                ->withHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
                ->withHeader('Permissions-Policy', 'geolocation=(), microphone=()');
        }

        return $response;
    }
}

A page rendered by the classic MVC path is not a Response object; there, call PHP's header() before output, or set them in the web server. X-XSS-Protection is omitted on purpose — current browsers ignore it, and the CSP is what stops script injection.

Reference

For complete technical details, see the inline documentation in the framework source.

Related Guides: - Pramnos_Authentication_Guide.md — Login lockout, 2FA/TOTP, OAuth2 - Pramnos_Framework_Guide.md — Middleware pipeline, CORS, exception handler - Pramnos_Authorization_Guide.md — Gates, permissions, roles, administration access