11 August 2026¶
6 changes:
- Five rolled-up views were frozen on every database without TimescaleDB
- A model may give
load()whatever parameters it needs - Five developer panels had been empty for years, and nothing said why
- The rate limiter counted the proxy, and counted it badly
- A human check that prices spam instead of pretending to detect it
getInstance()is a factory, and two call sites forgot
Five rolled-up views were frozen on every database without TimescaleDB¶
They existed. They answered every query. And every answer was the one they gave on the day the migration created them.
Fixed¶
Four migrations create the same thing twice: a TimescaleDB continuous aggregate where the extension is present, and a plain materialized view where it is not. Only the first branch registered a refresh policy.
$schema->ifCapable(TIMESCALEDB,
function () use ($schema) {
$schema->createContinuousAggregate('authserver.daily_activity_summary', …);
$schema->addContinuousAggregatePolicy(…); // ← the refresh
},
function () use ($schema) {
$schema->createMaterializedView('authserver.daily_activity_summary', $sql);
} // ← no refresh, ever
);
PostgreSQL never refreshes a materialized view on its own. So on plain
PostgreSQL and MySQL, daily_activity_summary, daily_2fa_stats,
tokenactions_hourly, application_stats_daily and application_stats_hourly
were stale from the moment they were created — which is worse than missing. A
view that is not there fails and gets noticed; one that quietly returns
month-old numbers gets believed.
The mechanism was never missing. addContinuousAggregatePolicy() already
branches by backend: a native job on TimescaleDB, a row in
pramnos.framework_policies executed by the policy engine everywhere else. It
simply was not called on the second path.
The parameters now live in Pramnos\Database\ContinuousAggregateRegistry, and
the call sits outside the capability check, so both branches get a refresh.
A test reads the migration files and fails if anyone puts it back inside one —
nothing in code shows the absence of a call, which is why this survived.
Added¶
timescale:ensure now repairs these too, and — the part that matters — it does
that before deciding whether TimescaleDB is present. The command used to
report "no extension here" and stop, which meant it refused to run on exactly
the backend whose views were frozen. --dry-run lists the views with no refresh
and says what stale means for them.
2026_08_11_000001 does the same automatically, because the four migrations are
recorded as applied on every installation that already ran them and will never
run again — the same gap the repair exists to close. It adds nothing to a view
that already refreshes, and creates no view that is not there.
Two supporting methods on SchemaBuilder:
hasView()— a continuous aggregate is not a table and neither is a materialized view, sohasTable()finds none of them, and asking it about an aggregate quietly answers no.hasContinuousAggregatePolicy()— on TimescaleDB the refresh job cannot be found by the view's name.timescaledb_information.jobsrecords the materialization hypertable (_timescaledb_internal._materialized_hypertable_N), so the lookup goes throughcontinuous_aggregates. Written the obvious way, the check answers "no policy" for every aggregate that has one — and a repair built on it would add a second policy on every run.
Verified against both backends, including a PostgreSQL database with the TimescaleDB extension dropped — what an installation without it actually looks like, rather than a simulation of one.
A model may give load() whatever parameters it needs¶
The listing helpers used to find a model's table by calling $this->load(0) —
not to load anything, but hoping the subclass would set $_dbtable as a side
effect. That quietly required every model's load() to accept exactly one
argument.
Fixed¶
Three places in Model did the same thing: when $_dbtable was unset, call
load(0) and hope. It coupled table discovery to record loading, issued a
pointless lookup for id 0, and assumed a signature the base has no business
assuming. A model written as load($username, $type) got an
ArgumentCountError raised from inside a framework method, about a call its
author never made.
The assumption could not simply be enforced, because enforcing it would cost more than it saved. PHP only lets a child add optional parameters, so any declaration in the base rejects a child that needs its own:
abstract load($id) → child load($id, $x = null) allowed
abstract load($id) → child load($user, $type) Fatal: must be compatible
abstract load(...$a) → child load($id) Fatal: must be compatible
load($id = null) → child load($user, $type) Fatal: must be compatible
Not even a variadic declaration is neutral. Pramnos\Auth\Application already
loads by id plus two options, and models are expected to keep that freedom.
So load() stays undeclared and the base gets a hook of its own:
/** Set $_dbtable here when the model works its name out at runtime. */
protected function initTable(): void
{
$this->_dbtable = 'readings_' . $this->tenant;
}
Models that declare protected $_dbtable — nearly all of them — need nothing.
Models still written the old way keep working: when neither a property nor
initTable() produced a name, the base falls back to the historical load(0)
call. It first checks, by reflection, that load() can accept one argument. A
model whose load() needs more gets a LogicException naming the class and the
fix, instead of an ArgumentCountError from a call that should never have been
attempted — and the call is not attempted.
What each caller does with an unresolved table is unchanged: _getJsonList()
still returns an empty list, the others still skip the query. Turning that into
an exception would have changed three public methods for no gain.
Five developer panels had been empty for years, and nothing said why¶
They queried a table called tokens. The framework's is called usertokens.
Every query threw, an empty catch swallowed it, and the panel rendered as
"nothing to show".
Fixed¶
The DevPanel's Active Sessions, Login Lockouts, Token detail, Slow users and Queue stats sections were broken on every installation. Not subtly:
| Asked for | Actually exists |
|---|---|
tokens |
usertokens |
last_used, ip_address |
lastused, ipaddress |
tokentype IN (1,3) |
a text column — auth, access_token |
loginlockouts with identifier, lockout_until |
authserver.loginlockouts with displayvalue, lockoutuntil |
queue_jobs |
queueitems |
{$prefix} from a PREFIX constant |
never defined anywhere in the framework |
The test above them was green, because an empty panel still contains its own headings. It asserts on the values now.
SchemaBuilder's TimescaleDB methods no longer return false for two
different things. They returned it when the backend lacked the extension —
documented, deliberate — and when the statement failed. Both silent, so a
migration whose createHypertable() failed looked exactly like one running on
MySQL, and the table stayed unpartitioned with nothing anywhere saying so. The
signature is unchanged; what changed is that only the no-op is quiet now, and a
real failure is logged with the statement that produced it.
keys() says whether it can look. Adapters that cannot enumerate — File,
Array, Memcached — returned [], which reads exactly like "nothing matched".
supportsKeyEnumeration() answers the question directly, and FlatCache says
so once in the log rather than handing back a convincing empty list.
ApiAccount::revokeToken() is now actually tested. It carried
@codeCoverageIgnore — exercised via integration, not unit tests, and no such
test existed. Rather than delete the claim, three integration tests make it
true: the row is deactivated, the token stops identifying its user, and
revoking an unknown token is harmless. That last one matters because API tokens
have no expiry by default on existing installations — revocation is the only
thing that ends a session.
Every silent catch in src/ now says why the failure does not matter.
There were 68; 30 said nothing at all. Swallowing an exception is sometimes
right — instrumentation must not break a response, a webhook must answer even
if its log write fails — but doing it wordlessly leaves the next reader unable
to tell a considered decision from an unfinished one.
Added¶
SilentFailureTest reads the whole of src/ and fails when a catch discards
an exception without a comment, or when a coverage exclusion promises tests
that do not mention the class. It chases no particular bug; it makes the shape
expensive. Every serious finding in this audit had that shape — a failure that
looked like a result.
The rate limiter counted the proxy, and counted it badly¶
Behind a reverse proxy every visitor shared one bucket, so the limit fired for
everybody at once. Reading X-Forwarded-For to fix that would have been worse:
the header is written by the client.
Added¶
Request::clientIp() — one answer to "who is the client".
The framework had no answer. It had seventeen inline reads of
$_SERVER['REMOTE_ADDR'] with four different fallbacks ('0.0.0.0', '',
'none', 'unknown'), and three places that read CF-Connecting-IP with no
check at all.
REMOTE_ADDR is the connecting peer. Behind a proxy, a CDN or a load balancer
that peer is the proxy — one address for the whole world. A per-IP rate limit
becomes a global one, and anything binding to the address binds every visitor to
the same value.
The obvious repair is a total bypass, which is the interesting part. A client
that sets a fresh random X-Forwarded-For on every request gets a fresh 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. A control that
appears to work and does not is worse than no control at all.
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 of the chain and is never trusted.
// app.php — nothing is trusted by default
'trusted_proxies' => ['private_ranges'],
'trusted_proxies' => ['cloudflare'],
'trusted_proxies' => ['10.0.0.0/8', '2001:db8::/32', '192.0.2.7'],
With the list empty the answer is REMOTE_ADDR, unchanged. That is both the
safe default and the previous behaviour, so an application that does not opt in
sees no difference.
X-Real-IP is deliberately not consulted: it is single-valued, so there is no
chain to walk. Forwarded (RFC 7239) is understood, including its obfuscated
for=_hidden identifiers, which are dropped rather than used as an address.
Cache::increment() — an atomic counter. Redis INCRBY and Memcached
increment, with supportsAtomicCounter() to ask first. The Memcached adapter
had no counter at all; it creates one through add, which is atomic, so a race
to create it loses no increments either way.
supportsAtomicCounter() is a declaration on the adapter, not a
method_exists() probe — and that distinction was itself a bug in the first
version of this work. Every adapter inherits a working increment() from
AbstractAdapter, so probing for the method reported the File adapter as
atomic and sent the limiter down the exact-counting path on a backend that
loses increments. The middleware's own tests could not have caught it: they used
doubles that answered the question themselves. AtomicCounterCapabilityTest
asks the real adapters instead.
TooManyRequestsException. An \Exception with code 429 — so every
existing handler is unaffected — that carries a Retry-After value.
Fixed¶
The sliding window undercounted exactly when it mattered. RateLimitMiddleware
did load → filter → count → append → save with no lock and no compare-and-set.
Two concurrent requests both read the same list, both append, and the second
save overwrites the first. Under a burst of N simultaneous requests the stored
count could advance by as little as 1 — and a flood is concurrent by definition,
while the slow trickle it counted perfectly is the case nobody needed protection
from.
Where the cache can count atomically the limit is now a fixed window on the server's own counter, which is exact under concurrency. The trade is at the window boundary: up to 2× the limit can pass across one. For a spam gate that is the better bargain.
Where it cannot — the Array and File adapters — the sliding window remains, and
RateLimitConcurrencyTest measures the loss rather than asserting it away.
The docblock says the count is approximate there. Documented slop is defensible;
silent slop in a security control is not.
ThrottleMiddleware had the same shape and now counts through apcu_inc(),
which creates and increments in one operation. An application that overrode its
storage seams keeps its own behaviour — routing around a subclass's storage
would be a worse bug than the race it fixes.
The Redis counter's expiry no longer slides by default. RedisAdapter::increment()
reset the TTL on every call, so a key under sustained traffic never expired: the
count climbed for ever and the client stayed locked out permanently. The expiry
is now applied by the call that creates the key. FlatCache asks for the
sliding behaviour explicitly, because that is what its own documented contract
promises and applications rely on it for login-attempt counters.
A dropped Redis connection no longer means no limit. increment() returns
false for "the counter did not work", which is not zero. Reading it as an empty
bucket would open the door at the moment the site is under strain.
Retry-After goes through the response. Both limiters emitted it with a bare
header() call — invisible to anything inspecting or buffering the response, and
silent in CLI and tests. It is now carried on the exception and set by
ExceptionHandler::render(), and it says how much of the window is actually
left rather than always the full length.
CF-Connecting-IP was trusted unconditionally in three places —
User\Token, SessionTrackingMiddleware and the System\Session addon — so any
client could dictate the address written into its own session and token records.
Those records read as evidence. They now go through the resolver.
Installations behind Cloudflare must configure this
This is the one change that needs action. An application relying on the old
unconditional CF-Connecting-IP read will record the Cloudflare edge address
instead of the visitor's until it sets 'trusted_proxies' => ['cloudflare']
in app.php. Nothing breaks, but the addresses in new session and token rows
are wrong until it does.
ClientIpResolver::CLOUDFLARE_RANGES is a snapshot of the published list and
does change; an installation that cares should pin its own copy.
A human check that prices spam instead of pretending to detect it¶
The framework now ships a proof-of-work check for public, unauthenticated writes. It is not a CAPTCHA, and the reason is the same reason this codebase keeps finding bugs: a control that appears to work and does not is worse than no control at all.
Why not a CAPTCHA¶
Distorted text is solved by commodity OCR, and by any vision model, at rates that make it decorative. An image grid needs a labelled dataset nobody has. Either would be defeated for free by a script while every application that adopted it believed itself protected — and the cost would be paid by real users, disproportionately those using a screen reader.
Hosted options solve the detection problem by putting a third party's script on the first page a visitor ever sees, and sending their traffic there. For an application that wanted to answer "must we take a third party onto the signup page?" with a permanent no, that is not an option either.
Added¶
Pramnos\Security\HumanCheck — proof-of-work. The client is given a
challenge and must find a nonce whose SHA-256 begins with a required number of
zero bits. There is no shortcut: solving it is paying the cost.
$check = new HumanCheck(difficultyMs: 300);
$challenge = $check->challenge(); // hand to the page
// on submit
if (!$check->verify($submitted['challenge'], $submitted['solution'])) {
// refuse
}
The properties are the opposite of a puzzle's. It is arithmetic rather than perception, so there is nothing a model can recognise better than an honest client can compute. Nothing leaves the server. It runs in a Web Worker while the visitor is still typing, so by submit time it is normally already done. And there is nothing to perceive, so it is accessible by construction.
What it does not do, stated in the class docblock as well as here: it does
not stop spam, it prices it. An attacker with a botnet and free CPU still gets
through. What changes is that a thousand signups cost real compute instead of
nothing. That is the right defence against volume and no defence at all against
a targeted attack — code reading human_check: true must not conclude more
than "this submission cost something", and in particular must not conclude that
a human was involved.
It costs the visitor battery. Difficulty is therefore expressed in milliseconds of work on a mid-range phone rather than as a leading-zero count nobody can reason about, defaults to a modest 300ms, and is set per call site — a signup form and a login form do not deserve the same cost. The assumed hash rate is deliberately pessimistic: guessing high would make "300ms" mean several seconds on the slowest devices, which are the ones least able to spare it.
The challenge stores nothing. It is HMAC-signed and carries its own difficulty and expiry, so handing one out costs a hash and cannot be used to fill a cache. Editing the difficulty invalidates the signature — without that, a client simply asks for zero work.
It is single-use, atomically. A solved challenge replayed a thousand times
is the obvious bypass, and without this the check costs an attacker one unit of
work in total. The claim goes through Cache::increment(), so first-use-wins is
one indivisible operation rather than a read-then-write that two simultaneous
replays would both pass. On adapters that cannot count atomically the fallback
closes the replay window but not the race — an installation using this to do
security work belongs on Redis or Memcached, and the docblock says so.
scaffolding/assets/js/pf-humancheck.js — the client, dependency-free and
served from the application's own origin. A proof-of-work widget that loads from
a CDN has given back the property it existed to provide. The worker is built
from a blob of the file's own source, so there is no second file to keep in
step, and it reports progress every 20,000 hashes for the slow devices where
this takes a visible moment.
Not built, deliberately¶
A signed single-use form token and a submission-timing floor were considered
alongside. Both are nearly free and both are honest only if labelled as
filters rather than controls — each is trivially bypassed by anyone who looks.
The form token shares its entire implementation with the challenge above, so an
application wanting one can mint a HumanCheck challenge at difficulty zero and
verify it on submit. A honeypot field is the weakest of the three and is worth a
line of markup, never a line in a security report.
getInstance() is a factory, and two call sites forgot¶
Reading one setting could build an entire application — database, language and session included. On the connection path that meant querying the database through the connection still being opened.
Added¶
Application::currentInstance(): ?self — the current application if one
exists, without creating one.
$app = \Pramnos\Application\Application::currentInstance();
if ($app === null) {
// no application: fall back, do not build one
}
getInstance() is a factory. Given no existing instance it reads app.php,
defines constants and runs the whole constructor, which sets up the database,
the language and the session. That is correct for a caller that wants an
application, and wrong for one that only wants to read a setting.
Low-level code should use currentInstance(). The distinction is not academic:
both bugs below were the same mistake, made in the same change.
Fixed¶
A CSRF check could boot an application. Session::getFingerprint() began
asking for the trusted-proxy list, which reads application config. CSRF
verification and rate-limit middleware run before the request has decided
anything — they are the two places least able to absorb a side effect of that
size. A reference application's login tests failed on "security token invalid or
expired" because a second application was being constructed underneath them.
The connection path resolved configuration. Database::setTrackingInfo()
runs while the PostgreSQL connection is being opened, stamping tracing variables
onto it, and called Application::getInstance() to read the application name.
Building an application there sets up Settings, which queries the database —
through the connection still being established. It surfaced as a MySQL-quoted
statement arriving at PostgreSQL:
Backticks untranslated and #PREFIX# empty: the signature of a query issued on
a connection that did not yet know its own driver. The !== null check beside
the call had already been written as though it could not construct anything; now
it cannot.
An empty REMOTE_ADDR is not an absent one. The CSRF fingerprint was
$_SERVER['REMOTE_ADDR'] ?? 'none', and ?? does not fire for the empty
string, so an empty value hashed as ''. A rewrite that substituted 'none'
for both cases changed the fingerprint — and the fingerprint is hashed into a
token issued by one request and verified by the next, so every form in flight
would have broken at deploy. The fallback now reproduces the original expression
exactly.
User serializes through __serialize(). It used __sleep(), which
returns property names that PHP then looks up on the object. Private
properties are stored under a mangled name, so serializing a subclass
instance — the normal case, since applications extend this class — emitted
"_userstable returned as member variable from __sleep() but does not exist"
for every private property, on every serialize.
If you overrode __sleep() on a User subclass
It is no longer called: __serialize() takes precedence. Rename your
override to __serialize() (returning the data array rather than a list of
names) and add the matching __unserialize(). The plaintext password is
still excluded, which is what this machinery exists for.
Added — a guard for the whole class of error¶
ConnectionPathPurityTest reads the source and fails when anything on the
connection-establishment path calls a configuration lookup. It is a source check
rather than a runtime one because the bug is structural: the call is wrong even
on the runs where it happens to work.
It earned its place immediately. It was written to cover a defect this work had
introduced, and the first thing it reported was the older one in
setTrackingInfo() that had been sitting there unnoticed.
How this was found¶
None of it by this framework's own suite, which is green at 8836 tests. All four
defects came from running a reference application's 5401-test suite against the
framework — a second application to construct, a REMOTE_ADDR set to an empty
string, a User subclass to serialize, a live connection to open. The framework
alone has none of those.
Both versions were then run against the same database: pinned release 5401 OK, this branch 5401 OK, identical assertion counts.