The self-hosted utility space shares a quiet truth: once a tool grows past the "one admin logs in" stage, the real work is not adding pages — it's deciding how to let a few people cooperate, who gets to approve dangerous operations, and how to persist all that state when you can't bank on a daemon, a database, or Redis. I recently read through the source of an open-source panel built for PHP shared hosting, and it answered all three with little more than .json files and file locks.
This is not a feature review. It's a look at the implementation trade-offs I found, and a few places where I genuinely don't know the answer.
Default-deny is where multi-user gets done right
The permission model is two layers: a role rank (viewer < operator < admin) plus an action whitelist. Ranking is just integer comparison — boring. The interesting part is the companion default policy: any endpoint not explicitly listed in the ACL map is admin-only. A lot of old panels default to "if it's not written, everyone can call it" — one unguarded endpoint and you leak. Here the default is rejection, and you opt in. That direction matters more than the fancy parts.
A path allowlist that's string-prefix matching raises flags
To limit read-only users to a subtree, the check looks like this:
strpos($normalized, rtrim($p, '/') . '/') === 0
That's raw string-prefix comparison. It does not resolve .., and it does not resolve symlinks. If an allowed directory contains a symlink pointing outside the allowed tree, can the read-only user traverse it? That depends on whether the file layer above normalizes via realpath. I stopped at the ACL layer and didn't chase the whole chain, so it stays an open question. Even a one-line comment ("the layer above normalizes paths") would save everyone the audit.
Double approval is, in effect, a re-dispatch with the gate bypassed
Sensitive operations (DB import, bulk purge, app uninstall) are intercepted and routed through a second-admin approval. Two decisions here are right: a single-admin deployment gets 409 outright instead of a "just let it through" fallback, and the requester cannot be the approver.
The execution path is the part worth unpacking. When the approver clicks approve, the code rebuilds the router, overrides the request body with the stored payload, opens a capture buffer, sets a bypass flag, and synchronously re-dispatches the request inside the approver's own HTTP call. Two consequences:
- A slow operation blocks the approver's request until it finishes. For a large import that's a UX and timeout question.
- The payload is a snapshot taken at submit time; by the time it runs, the underlying resource may have changed. So "approve" strictly means "approve the action as-of submit", not "apply to current state". Fine in most cases, worth remembering for auditing and error handling.
The rate limiter is honestly not a sliding window
Rate limiting applies only to non-admin roles. State lives in {userid}_{minute-bucket}.json, and the check sums the current bucket plus the previous one to approximate "roughly two minutes":
foreach (array($bucket, $bucket - 1) as $b) { $cur += count; }
That's not a sliding window; accuracy depends on where requests land relative to minute boundaries, and it skews at the seams. Fine as coarse shielding, but don't describe it as precise. One thing that caught my eye: reads and writes each count as 1 (no write weighting), while the read-only role's write quota is one request per minute. That is aggressive and likely to trip legitimate users — worth confirming it's intentional.
An in-process WAF lives and dies by its regexes
The WAF runs on every PHP request, scanning query, body and URI with regex. Three implications worth separating:
- It blocks inside the PHP process. By the time it fires, PHP has already handled the request; it just stops it with
exit()before business code runs. This is an application-layer filter, not an edge firewall. - The rules are keyword regexes, not semantic analysis. The command-injection rule contains
\b(?:ls|dir|cat|grep|find|exec|system|...)\b, so any plaintext containing "cat" or "find" trips it — search results, documents, an ordinary English sentence. And URL encoding or comment obfuscation sidesteps plain-text regex. It's a coarse sieve. - It ships a per-IP 1000 req/hour all-up limit via a locked JSON file — a pragmatic last line of defense on shared hosting.
For shared hosting, the survival metric for this kind of WAF is the false-positive rate. Killing legitimate business costs more than the attacks it stops. "Rules can be turned off and scoped down" beats "the bigger the rule surface the better".
A convention everyone should copy: say plainly when a capability is missing
When the host lacks a capability, the API returns 501 with a machine-readable code (think composer_unavailable, fpm_not_applicable). Shared hosts routinely miss extensions or disable functions, and most panels just throw a generic 500 that sends you guessing. Separating "this host can't do it" from "the software is broken" is more valuable than a dozen more pages.
Open questions I'm keeping
- Does the file layer above the path allowlist actually normalize
realpathand symlinks? - With snapshot-replay approvals, when the resource changed after submit — replay anyway, or error and ask to resubmit?
- All state in JSON with file locks: at what concurrency and volume does that break, and where's the line where SQLite becomes worth it?
The source I read is an open PHP panel; each module lives as one file under its backend/ directory, easy to browse: https://github.com/YQteam-dyq/Go.js-Lite/. If you've measured any of the above, I'd like to hear it.
Top comments (0)