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¶
Both CSRF paths verify with hash_equals(). The synchronizer token
(verifyCsrfToken()) always did; the legacy fingerprint check (checkTokenValue(),
which the account controllers, the settings form and the scaffolded templates still
use) compared with === until 2026-08-31. Neither is now the weaker choice, so a
project on the legacy path is not on a worse one.
Session Token Hardening (v1.2)¶
| Change | Before | After |
|---|---|---|
| Session token entropy | random_bytes(5) → 40-bit |
random_bytes(32) → 256-bit |
| Fingerprint algorithm | md5($ua . $ip . $token) |
hash_hmac('sha256', $ua . $ip, $token) |
| Existing sessions | — | Silently upgraded on first request |
New 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) |
CsrfMiddleware¶
Validates the token on POST, PUT, PATCH, DELETE. Passes GET, HEAD, OPTIONS, TRACE through unchecked.
// 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¶
fetch('/api/data', {
method: 'POST',
headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});
Legacy API (unchanged)¶
CsrfMiddleware API¶
| Method | Description |
|---|---|
new CsrfMiddleware(string $fieldName = '_csrf_token') |
Constructor; custom field name for legacy forms |
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¶
Session Cookie Hardening (v1.2)¶
Three security improvements added transparently:
- Session fixation prevention —
Session::reset()now callssession_regenerate_id(true)on login/logout. An attacker who planted a session ID is immediately locked out. - Strict session ID mode —
session.use_strict_mode = 1is set beforesession_start(). PHP rejects any session ID not generated by itself. - HTTPS detection fix —
Session::isHttps()now accepts both'on'(Apache/nginx) and'1'(IIS/CGI).
Usage¶
The hardening is transparent — existing login/logout code gets protection automatically:
// Session::start() sets strict mode automatically
$session = Session::getInstance();
$session->start();
// Session::reset() regenerates session ID + CSRF token
// Call AFTER setting session data on login/logout
$session->set('userid', $user->userid);
$session->reset();
Session::isHttps()¶
// Before (fragile — missed IIS/CGI '1' value)
if ($_SERVER['HTTPS'] === 'on') { ... }
// After (handles 'on', '1' consistently)
if (Session::isHttps()) {
// set Secure cookie, redirect HTTP→HTTPS, etc.
}
Session Cookie Settings (already set in v1.1, unchanged)¶
// HttpOnly, SameSite=Lax, and Secure (when HTTPS) are set automatically
'session' => [
'secure' => true, // HTTPS only
'http_only' => true, // No JavaScript access
'same_site' => 'Lax', // CSRF protection
],
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 |
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.
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. 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. Two were missed when the column was split, both written as
where('ut.token', …): the aliased form, which a grep for where('token' does not see.
Oauth::selectTokenRow() made introspection answer {"active": false} for every token the
server had issued, and OAuth2Middleware::loadTokenFromDatabase() refused 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.
The mocked unit tests over introspect() and revoke() stayed green through both, which is the
other half of the lesson: a mocked query builder returns the prepared row whatever the WHERE
says, so it cannot answer a question about a column.
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. Currently that is smtp_pass.
Nothing else changes: Settings::getSetting('smtp_pass') returns the plaintext
it always did, and setSetting() takes a plaintext. Only the row is different.
An existing installation needs no migration — the row converts itself the next
time the settings screen is saved — and if APP_KEY is not set the value is
stored as before, because a settings screen that refuses to save is worse than
the problem it would be avoiding.
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¶
// Login
$user = \Pramnos\User\User::authenticate($username, $password);
if ($user) {
$session->set('userid', $user->userid);
// Success
} else {
// Authentication failed
}
// Logout
$session->destroy();
Login Lockout¶
Prevent brute-force attacks:
$lockout = new \Pramnos\Auth\Loginlockout($user);
// Check if user is locked out
if ($lockout->isLocked()) {
return "Too many login attempts. Try again in " . $lockout->getRemainingTime() . " seconds";
}
// Record failed attempt
$lockout->recordFailure();
// Clear failures on success
$lockout->clearFailures();
Two-Factor Authentication¶
Protect accounts with 2FA:
$totp = new \Pramnos\Auth\TOTPHelper($user);
// Generate secret
$secret = $totp->generateSecret();
// Verify code
if ($totp->verify($code)) {
// Code valid
} else {
// Code invalid
}
Content Security Policy¶
CSP Headers¶
Protect against XSS by restricting script sources:
// In controller or middleware
$response->setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'nonce-" . $nonce . "'; style-src 'unsafe-inline'");
// In template
<script nonce="<?php echo $nonce; ?>">
// Only this script executes
</script>
Nonce Generation¶
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.
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, and until those
paths existed the check could not be solved there at all: the form submitted an empty
solution, the server refused it, and the visitor read "your browser must support
JavaScript" while using a browser that supported it perfectly well. It happened on a login
form.
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, which is what it was first. 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 had to copy the partial in to use the feature and then owned 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.
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.
}
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.
Dependency Security¶
Keep Dependencies Updated¶
# Check for security vulnerabilities
composer audit
# Update dependencies
composer update
# Require security patches
composer require symfony/security --security-advisories
Security Headers¶
Recommended Headers¶
// In base controller or middleware
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
$response->setHeader('X-XSS-Protection', '1; mode=block');
$response->setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->setHeader('Permissions-Policy', 'geolocation=(), microphone=()');
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 — Policy engine, gates, access control