Skip to content

24 September 2026

10 changes:

  • A working single server was being shown a red badge for a correct choice
  • Http\Client could not refuse a redirect, so it could not fetch a URL a user chose
  • api:docs could 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 => false admitted the VPN
  • The scaffold put the API documentation in .gitignore, so production answered 403
  • ApiAuthMiddleware owned 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:

// app/config/app.php — only a deployment with more than one server needs this
'servers' => 2,

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_storage is ok on files, the servers declaration, and a rewritten rule for choosing a status: before reaching for anything above ok, ask what the reader is supposed to do. If the answer is "nothing, this installation is correct", it is ok, and whatever an operator might want goes in details.
  • 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() and maxRedirects(int). The smallest thing that was missing: the caller sees the 30x and 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.
    • https only; forUserSuppliedUrl(true) also allows http, 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_RANGE is most of it — including 169.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 the 30x rather 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 ClientException naming 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

Scanned src/Api/Controllers … Wrote 0 path(s), 0 operation(s)

…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 the finally, and why an empty document is refused.
  • Upgrade Guide — two rows: the header-case fix, and api:docs now 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(): https only, every address the host resolves to must be public, the checked address is pinned for the connection, and a 30x is 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_error prefix 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_by on applications.oauth2_webhook_endpoints (client | admin, default client), 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.webhooks in app.php: allow_private (default true) lets a client-registered endpoint resolve to the private network; allow_private_ranges names CIDR ranges in addition — or, with allow_private off, instead; require_https (default true) can be turned off to accept http:// 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() and OutboundUrl::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.
  • WebhookService owns endpoint storage — saveEndpoint(), endpointsFor(), rotateEndpointSecret(), deleteEndpoint() — shared by the API controller and the screen, and WebhookService::EVENT_TYPES is 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 /0 is 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_RANGES is "is this the public internet". PRIVATE_NETWORK_RANGES is "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\Client had 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_RANGES must fail isPublicAddress(); one of the five did not. Plus the boundary cases either side of CGNAT, because an off-by-one in a /10 is the next version of this.

Documentation

  • AuthServer Integration Guide — allow_private => false really 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 => false an 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 .gitignore would 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-code after an api:docs run 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

// app/config/app.php
'public_api_paths' => ['/1.0/wordpress/pair', '/1.0/hooks/*'],

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. init ignores www/assets/spa/.vite/hot instead.
  • That exception is load-bearing, not tidiness. npm run dev writes 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.json beside 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 .gitignore carries 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 .gitignore that 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/hot exception 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:

the CSRF check did not run: | REQUEST_URI='/' | ownUri='' | method='GET'

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 in src/ 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.
  • DebugGrantDispatchTest declares 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 POST is still a POST after another request is built, the URI likewise, and resetInstance() 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 $_SERVER after calling create().