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"}}withCache-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¶
- Strict session ids.
Session::start()setssession.use_strict_mode = 1beforesession_start(), so PHP refuses a session id it did not generate. - A new id at every privilege change.
Session::regenerateId()callssession_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. - Cookie flags. The session cookie is always
HttpOnlyandSameSite=Lax, andSecurewheneverSession::isHttps()is true. These are set inSession::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()¶
Accepts HTTPS set to 'on' (Apache/nginx) or '1' (IIS/CGI), and X-Forwarded-Proto from a
trusted proxy — see trusted proxies.
Cookie consent (EU)¶
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.
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 + windowon 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
1disables the limiter rather than tightening it.attempts >= 0is true of every attempt, so a mistyped0would 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.
userid0 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:
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 --missinglists 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 writesauth.twofactor_resetto 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.
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:
- Encrypted (an
APP_KEY, and the setting notfalse): every token the framework writes is stored encrypted, and the migration encrypts the live rows it fills. A lookup ontokenmisses them. - Not encrypted (no key, or
encrypt_tokensfalse): 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 ontokenkeep working.
Token::encryptsAtRest() answers which. So the order is:
- Set
'encrypt_tokens' => falseand run the migration. - Change the application's own lookups to
->where('token_lookup', Token::lookup($presented)). - 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->tokenbecomes the stored value after a save. So a secondsave()does not encrypt the plaintext again under a fresh nonce, rewriting the column for no change, and the object matches what aload()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_passis 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_dsnis refused (Settings::KEY_REQUIRED_SETTINGS). It is optional —db-inspectuses the ordinary connection without it — so storing a database password in the clear would buy nothing. Runkey:generatefirst.
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> |
<script>alert(1)</script> |
" onclick="alert(1) |
" onclick="alert(1) |
it's fine |
it's fine |
AT&T |
AT&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 filterjavascript: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:
¶
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.
nullor''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
registerandforgotbeforelogin. 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 isHumanCheck challenge failed for login: …orHumanCheck verification failed for login: …, in theauthchannel.
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.
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:
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.
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 . $pathagainst your own site fails, because that name resolves locally to a private address. Read the file from disk instead. $fakeRefis 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_fopenis 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¶
- The scope.
db-inspectrequiresmcp: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 inscopes_supportedeither. It is checked when the tool list is built and again when the call arrives. It inheritsmcp;system:admindeliberately 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. - 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.
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.- 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:
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:
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¶
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¶
Recommended 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