24 September 2026¶
10 changes:
- A working single server was being shown a red badge for a correct choice
Http\Clientcould not refuse a redirect, so it could not fetch a URL a user choseapi:docscould not see routes registered on the router, and wrote nothing over something- A relying party could point the server's webhook deliveries at its own network
- Webhook endpoints on the administration screen, and the private network allowed by default
- Carrier-grade NAT counted as a public address, so
allow_private => falseadmitted the VPN - The scaffold put the API documentation in
.gitignore, so production answered 403 ApiAuthMiddlewareowned the whole auth surface, with no way in for an application- The SPA bundle was ignored, and no deploy builds it — so production had no front end
- A CSRF test passed for the wrong reason, because a declared POST did not survive
A working single server was being shown a red badge for a correct choice¶
Yesterday's notice status stopped /health/check answering 503 for a correct
installation. It did not stop the dashboard putting this beside session_storage, in a
red pill, at full width:
Sessions are on "files", which is local to this machine. Correct on a single server; on more than one a visitor is signed out whenever the load balancer sends them to a different node.
Two separate mistakes, and the second one is the one that matters.
The check was reporting a correct choice as a fault¶
The existence of a shared-store option does not make not using it a fault. On one
machine files is PHP's default: free, no dependency, nothing to configure, and right.
This check answered degraded, then notice, and both were a way of saying "you have not
configured the thing you do not need".
It answers ok now, with the store still named — because the value of this check was
never the severity. It is the detail: details.handler and details.path are how
somebody finds a Redis pointed at the wrong host, and that works identically under ok.
It becomes degraded when the application says there is more than one node:
Declared rather than detected, because it cannot be detected: a node has no way to count its siblings behind a balancer, and guessing from the environment would make the answer depend on which machine answered the probe. One is assumed until you say otherwise, which is the default that decides what every scaffolded project sees on the day it is generated.
A handler the check does not recognise — user, or an extension it has not heard of —
stays a notice. It may reach a second server and it may not, and either answer would be
a guess. That is now the only thing notice is for.
And the dashboard widget was rendering every non-ok status as red, with the whole message inside the badge¶
<?php if ($result['status'] === 'ok'): ?>
<span class="badge badge-success">OK</span>
<?php elseif ($result['status'] === 'warn'): ?>
…
<?php else: ?>
<span class="badge badge-error"><?php echo $result['message']; ?></span>
<?php endif; ?>
warn is not a status this framework produces. HealthStatus is ok, notice,
degraded, down — so that branch has never once executed, and everything that was not
ok fell through to the red one. degraded, which has a yellow colour defined for it
everywhere else, rendered red on this widget; so did notice, the status whose entire
purpose is not to alarm anybody.
And the message went inside the badge, so a check with a sentence to say became a paragraph in an alarm colour stretched across the card.
Fixed in all three themes: the badge carries the status, coloured from the four real ones, and the message sits beside it in ordinary text — and only when there is something to say, so a green row stays a green row.
Documentation¶
- Health Guide — why
session_storageisokon files, theserversdeclaration, and a rewritten rule for choosing a status: before reaching for anything aboveok, ask what the reader is supposed to do. If the answer is "nothing, this installation is correct", it isok, and whatever an operator might want goes indetails. - One server to several — declaring
'servers', since the framework cannot find out and this is the page somebody is reading when it becomes true. - Upgrade Guide — a row: declare
'servers'if you run more than one node, or this check will not tell you that local session files have stopped being correct.
Http\Client could not refuse a redirect, so it could not fetch a URL a user chose¶
FOLLOWLOCATION was on, MAXREDIRS was five, and there was no way to change either. That
makes the client unusable for the thing it is most often reached for.
Fetching a user-supplied URL is server-side request forgery unless the scheme is
restricted, the resolved address is not private, and redirects stop at a host that has
been checked. The third cannot be worked around from outside the client: a perfectly
public server answering 302 http://169.254.169.254/ defeats any amount of pre-flight DNS
checking, because the check happened before the hop.
It is not a niche need — "verify your site", "import from a URL", a webhook tester, an OG-preview fetcher, an RSS reader — and the cost of getting it wrong is cloud metadata credentials rather than a broken page.
Added¶
withoutRedirects()andmaxRedirects(int). The smallest thing that was missing: the caller sees the30xand decides.forUserSuppliedUrl()— the whole guard, in the framework rather than in a comment telling every application to be careful, because each one needs the same version of it and the one that writes its own gets it subtly wrong.httpsonly;forUserSuppliedUrl(true)also allowshttp, for a site that has not got a certificate yet. Refusing outright would push the application into writing its own client, which is the outcome this exists to avoid.- Every address the host resolves to must be public.
FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGEis most of it — including169.254/16, where cloud metadata lives, and::ffff:0:0/96, so an IPv4-mapped loopback cannot sneak through — plus carrier-grade NAT, the IETF and benchmarking blocks and multicast, which it misses. One non-public address among several refuses the whole host: which record the fetch happens to use is not a security property. - The resolved address is pinned for the connection. A guard that resolves and then lets cURL resolve again has not closed DNS rebinding.
- Redirects are followed by the client, one checked hop at a time. That is the
finding. A chain longer than
maxRedirects()returns the30xrather than raising — a redirect loop is a property of the site being fetched, not an error in the application fetching it.
- A refusal is a
ClientExceptionnaming the address. "It was refused for being internal" and "it was down" are different answers and the caller is usually showing one to a user.
Stated rather than implied: it cannot see a public host proxying into its own private
network, and it does not limit the response — pair it with maxResponseBytes() and
timeout().
Fixed, found while testing it¶
ClientResponse lowercases header names in the constructor. header() always looked them
up lowercased and the live path happened to build them that way, so the only responses
affected were hand-built ones — which is every fake in every test.
ClientResponse::make('', 302, ['Location' => …]) stored Location, and
header('Location') answered with an empty string. Nothing said so: an absent header and
an unreadable one look identical to a caller.
api:docs could not see routes registered on the router, and wrote nothing over something¶
…written over the previous document, exiting 0, in a line that reads like success.
The generator reads controller attributes. It cannot see routes registered on the router —
which is what the framework's own dispatcher reads, and what the scaffolder generates in
src/Api/routes.php. An installation with ninety-one addresses carried a ten-day-old
fossil describing four scaffold endpoints, because an empty scan had quietly replaced the
good one.
Added — Router::beginCollecting()¶
A route file registers and then dispatches: the scaffolded one ends
return $router->dispatch($newRequest);, because its return value is the response. So
anything wanting to see the routes could not simply include it — doing so runs a
controller. While collecting, every dispatch() is a no-op returning null and every
router built is kept.
Router::beginCollecting();
try {
include $routeFile;
} finally {
$routes = Router::stopCollecting();
}
The finally matters more than it looks: collecting is process-wide, and a route file that
raises would otherwise leave every later dispatch() in the process silently doing
nothing. There is a test for exactly that.
It is a read, not a sandbox — including a route file runs whatever else that file does.
Added — api:docs --routes=¶
OpenApiGenerator::fromRoutes() produces the surface: every address, every method, the
path parameters, a tag from the first segment that is not a version prefix, and a
bearerAuth requirement naming the scopes where the route declares permissions. What a
route can say is less than what an attribute can — no docblock, no parameter types, no
response schema, because a closure registered against a URI carries none of that — so
where both describe the same operation the attribute wins, merged per operation rather
than per path so a controller documenting one method does not erase the other three.
Fixed — it refuses to write an empty document¶
Zero operations is not a document, it is a failure, and writing it is the worst available
outcome. The command now exits non-zero, writes nothing, and names both remedies:
--routes= if the routes are on the router, --allow-empty if the API really has none
yet — a real state for a new project, and not one to guess at.
Documentation¶
- HTTP Client Guide — redirects, and "fetching a URL somebody else typed" with the table of what the guard does and the two things it does not.
- Routing Guide —
--routes,beginCollecting()with thefinally, and why an empty document is refused. - Upgrade Guide — two rows: the header-case fix, and
api:docsnow failing where it used to destroy a document and exit 0.
A relying party could point the server's webhook deliveries at its own network¶
/Webhook/register checked that endpoint_url was a URL and that it was https://, and
WebhookService::deliverEvent() POSTed to it with a bare cURL handle. So any application
holding client credentials could register https://10.0.0.5/…, https://127.0.0.1/… or
a name that resolved publicly at registration and privately later, and the delivery
schedule would send a signed request there. Blind — nothing a relying party can read
carries the response — but server-side request forgery all the same, and the guard for it
landed earlier today in Http\Client.
Fixed¶
- Delivery goes through
Client::post()->forUserSuppliedUrl()->withoutRedirects():httpsonly, every address the host resolves to must be public, the checked address is pinned for the connection, and a30xis a failed delivery rather than a hop. The response is capped at 4 KB, since only the first 200 bytes are kept for the error. - Registration refuses a host that resolves inside this network, so the relying party hears it at once instead of finding failed deliveries later. A name that does not resolve yet is accepted — DNS is often set up after registration, and the delivery checks again.
- The delivery tests answer through
Client::fake()at IP-literal endpoints, so the guard decides without DNS and nothing leaves the container. The refusal test registers a fake for every URL, so a guard that failed open would make it fail rather than pass.
Documentation¶
- AuthServer Integration Guide — the endpoint must be public, what registration and delivery each refuse, and the reason a refused delivery records.
- Upgrade Guide — a row: a receiver on an internal address stops receiving, and the
last_errorprefix changed.
Webhook endpoints on the administration screen, and the private network allowed by default¶
The entry above made every webhook delivery refuse an internal address. That was right for
loopback and the cloud metadata address and wrong for the usual deployment: an authorisation
server whose relying parties sit on the same VPN. And it surfaced a second gap — an
administrator could not see an application's webhook endpoints at all, let alone enter one;
the only way in was the application calling /Webhook/register.
Added¶
- A Webhooks card on
/admin/Applications/view/{appid}, in all three bundled themes: each endpoint with its event type, URL, who set it and its sent / pending / failed counts, plus forms to add or replace an endpoint, issue a new secret, and remove one. POST only, CSRF-checked. An administrator's endpoint is approved as entered — no review step — and its signing secret is shown once. registered_byonapplications.oauth2_webhook_endpoints(client|admin, defaultclient), by a new migration. An administrator's endpoint is delivered to as written; a client's goes through the guard. A client re-registering an administrator's entry makes it its own again, and guarded.authserver.webhooksinapp.php:allow_private(defaulttrue) lets a client-registered endpoint resolve to the private network;allow_private_rangesnames CIDR ranges in addition — or, withallow_privateoff, instead;require_https(defaulttrue) can be turned off to accepthttp://endpoints, from the API and the screen alike. It is not derived from this server's own development mode: the receivers are other people's applications, one of which may be under development on somebody's machine while this server is live.Http\Client::allowAddresses()andOutboundUrl::inRanges()/OutboundUrl::PRIVATE_NETWORK_RANGES: the guard can be told which non-public ranges the operator allows. The private-network list is RFC 1918, carrier-grade NAT and IPv6 unique-local, and deliberately not loopback or link-local.WebhookServiceowns endpoint storage —saveEndpoint(),endpointsFor(),rotateEndpointSecret(),deleteEndpoint()— shared by the API controller and the screen, andWebhookService::EVENT_TYPESis the one list of event types.
Fixed¶
OutboundUrl::inRanges()checks a prefix as text before reading it as a number:(int) 'abc'is 0, and/0is every address, so a typo in a range would have allowed everything.
Documentation¶
- AuthServer Integration Guide — where a delivery may go, the configuration table, and managing endpoints from the administration area.
- HTTP Client Guide — letting the guard reach your own network.
- Upgrade Guide — the internal-address row rewritten for the default, the migration, and the new methods on classes an application may subclass.
Carrier-grade NAT counted as a public address, so allow_private => false admitted the VPN¶
OutboundUrl::isPublicAddress() was FILTER_FLAG_NO_PRIV_RANGE|NO_RES_RANGE and nothing
else. Those two flags call 100.64.0.0/10 public — the same block the class's own
PRIVATE_NETWORK_RANGES names, twenty lines below, as "what Tailscale and several other
VPNs hand out".
One class, two answers about one address, and the contradiction was load-bearing rather
than untidy. WebhookService admits an endpoint that is isPublicAddress() || inRanges($allowed).
With allow_private => false the allowed list is empty, so a relying party registering
https://100.101.102.103/hook was checked against a list it was not on — and then waved
through as public anyway. The setting whose whole purpose is to keep the operator's own
network out did not keep it out.
Nothing was wrong with inRanges(), and it had a test asserting CGNAT is inside
PRIVATE_NETWORK_RANGES. Nothing asked the other half of the question.
Fixed¶
OutboundUrl::NEVER_PUBLIC_RANGES— CGNAT, the IETF protocol block (192.0.0.0/24), the benchmarking block (198.18.0.0/15) and multicast (224.0.0.0/4). The filter flags are the floor; this is the rest of it.- The two lists answer different questions and stay separate.
NEVER_PUBLIC_RANGESis "is this the public internet".PRIVATE_NETWORK_RANGESis "is this the kind of private network an organisation runs its own services on", and only the second is something an operator would sensibly switch on wholesale. Http\Clienthad its own copy of this predicate — the same flags plus the same range table — written a few hours earlier and, as it happens, the stricter of the two. Two answers to one security question is one answer too many, and the looser one was what the rest of the framework had started calling. It delegates now.- The regression test is an invariant rather than a list: nothing may be both private and
public. Every range in
PRIVATE_NETWORK_RANGESmust failisPublicAddress(); one of the five did not. Plus the boundary cases either side of CGNAT, because an off-by-one in a/10is the next version of this.
Documentation¶
- AuthServer Integration Guide —
allow_private => falsereally does exclude carrier-grade NAT, and why that was worth saying. - HTTP Client Guide — the two lists, and what each is for.
- Upgrade Guide — a row. Nothing changes on the default; with
allow_private => falsean endpoint on the Tailscale block is now refused and was accepted before.
The scaffold put the API documentation in .gitignore, so production answered 403¶
init appended www/api/openapi*.json and www/api/docs/ to a new project's
.gitignore, under a comment calling them generated output. So /api/openapi.json and
/api/docs/ answered 403 on every live site the scaffold has ever made — the only two
addresses an integrator ever opens.
Neither is build output in the sense that phrase usually carries. The document is written
by a CLI command that no deploy runs — the scaffolded deploy is git fetch,
git reset --hard, composer install, migrate — and the viewer is an 845-byte static
page with a <script src> to a CDN and a spec-url to its sibling. There is nothing to
build and nothing machine-specific in either. A file that is not in the commit is a file
production does not have.
It fails quietly in the way that matters: nobody notices locally, because both files are on disk and both work. It is only wrong on the server, where nobody is reading the API documentation of their own application.
Fixed¶
- The two lines are not written. What genuinely must stay out —
/vendor/,/var/,/.env,node_modules/,/app/keys/*.key— still does, and there is a test asserting that too, because a change that emptied.gitignorewould satisfy the first assertion and commit the project's private keys. - What keeps a committed artefact honest is a test that fails when it has drifted, not an
ignore rule. With the document under version control,
git diff --exit-codeafter anapi:docsrun is a complete check, and the guide says so.
Documentation¶
- Routing Guide — "commit the document and the viewer", with the drift check and the note that a project scaffolded earlier still has the two lines and must remove them itself.
- Upgrade Guide — a row, phrased so somebody whose
/api/docs/has been 403 can recognise their own symptom.
ApiAuthMiddleware owned the whole auth surface, with no way in for an application¶
Two symptoms, and they are the same gap seen twice.
Every route needed an apiKey before routing happened. An endpoint whose whole job is
to be reached by a caller that has no credential yet — a pairing handshake, an inbound
webhook from a third party, a device-code poll — could not exist. The only caller the
middleware exempted was the application's own signed-in page.
Authorization: Bearer was claimed. The middleware reads it as an access-token JWT and
answers InvalidAccessToken before the endpoint runs, so an application could not define
an authentication scheme of its own on the header every HTTP client already knows how to
send.
Both follow from the same thing: the pipeline is built inside exec() with no hook, so an
application could not add, remove or reorder middleware. The only seam left was
checkApiKey(), which is handed the key and nothing else — so the workaround was an
application reaching into $_SERVER['REQUEST_URI'], from inside a method about a key, to
work out which route it was on. That is the shape of a thing that breaks the day
somebody changes how the path is built.
Added — public_api_paths¶
Globs, matched against the request path with the query string ignored.
Api::publicApiPaths() is the override for a list that is computed rather than written.
Said plainly in the doc-block and the guide, because the feature is a hole by design: these addresses are open to the internet. The framework stops asking for a key and asks nothing else, so a signature, a one-time code or a rate limit is the endpoint's own job.
A request that cannot say what path it is on is not public. The decision reads
Request::ownRequestUri() rather than the process-wide static, because a static answers
with whatever was written last and this one decides whether authentication runs at all —
the same reasoning CsrfMiddleware::isExempt() is built on, and for the same reason.
Added — Api::configureApiPipeline()¶
Called after CORS and the JSON content type and before authentication. The position is
the whole point: middleware piped there runs first, so it can answer a request itself or
take Authorization before ApiAuthMiddleware reads it as a JWT.
It does nothing by default, and there is a test asserting that an application without an
override still gets APIKeyMissing — a hook that piped anything, or swallowed a request,
would change every existing API.
The test for the hook asserts by consequence rather than by inspecting the pipeline:
there is no API key on the request, so without it the answer would be APIKeyMissing, and
with it the application's own middleware answers.
Documentation¶
- API Guide — "an endpoint that must be reachable without a key" and "an authentication scheme of your own", both next to the middleware they qualify.
- Upgrade Guide — a row: nothing changes without an override, but a subclass that already declares either method name is a fatal at class load.
The SPA bundle was ignored, and no deploy builds it — so production had no front end¶
The same defect as the API document above, found while checking it, with a larger blast radius.
www/assets/spa/ was in the scaffolded .gitignore, marked "BUILD OUTPUT — generated,
never edit, never commit". That is the ordinary rule for build output and it assumes
something builds it on the other side. Nothing does. The webhook this framework
scaffolds runs git fetch, git reset --hard, composer install, migrate — no npm
install, no npm run build, and a production server usually has no Node at all.
So a scaffolded SPA project deployed with no front end. Invisible locally, where the files are on disk and everything works.
Fixed¶
- The bundle is committed.
initignoreswww/assets/spa/.vite/hotinstead. - That exception is load-bearing, not tidiness.
npm run devwrites this machine's dev-server origin into that file, and the shell loads modules from whatever it says — committed, it would point every visitor's browser at a developer's laptop..vite/manifest.jsonbeside it is the opposite: production reads it for the content-hashed filenames and must be committed. So the rule names one file, not the directory, and the generated.gitignorecarries a comment saying why. spa:build --help, the scaffolded README and the project map all said "never commit". They say the opposite now, with the reason, because a line that tells somebody the wrong thing three times is worse than one that says nothing.- The test asserts the pair together: the hot file is ignored and no rule ignores the
directory. A line scan rather than a pattern, because the comment lines legitimately
mention the path and a regex that ignored that would pass on a
.gitignorethat had gone back to ignoring everything.
To build on the server instead — a legitimate arrangement — put the directory back in
your own .gitignore and add the build to your deploy. The default has to be the one that
works with the deploy the framework actually generates.
Documentation¶
- Application Styles Guide — "the bundle is committed", the build-then-commit loop, and
the
.vite/hotexception with its reason. - Upgrade Guide — a row: an existing project still ignores the directory and has to
change its own
.gitignore.
A CSRF test passed for the wrong reason, because a declared POST did not survive¶
DebugGrantDispatchTest was green on its own and red in a full run. The assertion that
failed was the one that matters — a POST without a CSRF token is refused — and the
measured state at the moment of failure was:
method='GET', in a test whose setUp() had written Request::$requestMethod = 'POST'.
Request::__construct() copies $_SERVER['REQUEST_METHOD'] over that static whenever
the key is set, and Controller::_runThroughMiddleware() constructs a Request on every
middleware-guarded action — from inside the very dispatch under test. So the POST became a
GET on the way in, CsrfMiddleware skipped it as a safe method, and the check the test
exists to prove was never reached.
It was green for as long as some earlier test in the run happened to leave
REQUEST_METHOD set to POST. A test that passes because of what ran before it is not a
test, and this one was guarding a CSRF check.
Fixed¶
Request::create()writes$_SERVER['REQUEST_URI']and$_SERVER['REQUEST_METHOD']as well as the statics. Nothing insrc/calls it — it is how a test builds a request — so no application's runtime behaviour changes. What changes is that a created request survives contact with the framework, which is the whole point of creating one.DebugGrantDispatchTestdeclares the method the way the framework reads it, and restores it, so it no longer depends on what ran first.- Three assertions on the invariant: a created
POSTis still aPOSTafter another request is built, the URI likewise, andresetInstance()leaves the superglobals alone.
The one I got wrong on the way¶
The first version also had resetInstance() unset those superglobals — completing the
reset, it looked like. Four PageCacheTest cases went red immediately: the established way
to build a request for a test is to write $_SERVER, then reset, so that new
Request() reads it. Unsetting turned that into a request for nothing.
Reverted, and the reason is now in the method's doc-block with a test holding it, because "finish the reset" is a reasonable-sounding change somebody will try again.
Documentation¶
- Testing Guide — "saying what request a test is making", with why setting the static alone does not hold and why the failure looks like a pass.
- Upgrade Guide — a row for the only people this can reach: a consuming project's own
tests, if they assert on
$_SERVERafter callingcreate().