LibAdmin 0.2.0: the URL was a table name
The panel took the resource segment from the URL and used it directly as a table name, so any table in the database could be read, written and deleted through it, and one path interpolated it into raw SQL. Plus a plugin system where a broken plugin cannot take the panel down.
LibAdmin 0.2.0 closes a hole that let any table in the database be read, written and deleted through the panel, and adds a plugin system so third parties can extend it without editing it.
The hole
The controllers took the {resource} segment from the URL and used it as a
table name. Not as a key into a list of tables: as the name itself.
public function store(Request $request, string $resource): Response
{
$columns = DB::select("PRAGMA table_info($resource)");
$valid = array_column($columns, 'name');
$data = array_intersect_key($request->except(['_token']), array_flip($valid));
DB::table($resource)->insert($data);
}
Three separate problems in six lines.
The first line is SQL injection. $resource is interpolated into the
query. Nothing checks its shape.
The last line writes to whatever table the URL named. Not the tables the
panel manages: any table. POST /admin/resources/admin_users with an email
and a password creates an administrator. Anyone who could reach that route
could grant themselves an account.
And the column filter is not a filter. It asks the database which columns exist and accepts all of them, which is a definition of mass assignment rather than a defence against it. Any column in the table could be set, including the ones a form would never show.
destroy() was the same shape:
DB::table($resource)->where('id', $id)->delete();
Delete any row from any table.
What replaced it
A slug is resolved against the resources actually registered, and the table comes from the resolved resource's model:
$class = ResourceRegistry::resolve($slug); // null if not registered
$table = ResourceRegistry::tableFor($slug); // from the model, never the URL
A slug that resolves to nothing is a 404. Shape is checked first, so anything
carrying a quote or a semicolon is refused before the lookup even happens, and
admin_users is refused too: it is a perfectly well-formed identifier that
simply is not a registered resource.
The 404 deliberately says nothing about whether a table of that name exists. "No such resource" and "not registered" are different answers, and the difference is a way to map the schema.
Writable fields now come from the resource's own declaration:
public function fields(): array
{
return [
TextInput::make('title')->required(),
Textarea::make('body'),
];
}
Those two are what can be written. A form that posts is_admin writes nothing,
because the resource never said is_admin was a field. The allow-list comes
from the code, not from the schema and not from the request.
Three smaller things
Remember tokens were compared with ===. A plain comparison returns as
soon as two bytes differ, so how long it takes leaks how much of the token was
guessed correctly. hash_equals takes the same time either way.
A debug error_log printed on every console command. Someone left it in.
It appeared above the output of every php libxa invocation in any project
with the panel installed.
admin.path and admin.api.prefix did nothing. Both were documented
settings that the provider ignored in favour of a hardcoded 'admin'. That
matters more than it sounds, because the framework ships a Nova module which
also defaults to /admin: with the setting inert, installing both meant
whichever registered first won and the other silently did not exist. It works
now, so the two can coexist.
Migrations ran in the wrong order
They were named create_admin_users_table.php, create_roles_table.php and so
on, with no timestamp, so they ran alphabetically:
create_permission_role_table <- pivot
create_permissions_table <- the table it references
create_role_user_table <- pivot
create_roles_table <- the table it references
Both pivot tables were created before the tables their foreign keys point at. SQLite tolerates that. MySQL rejects it, so the package installed cleanly in development and failed on the first real database it met.
They now carry timestamps and run in dependency order.
Plugins
A plugin is a Composer package that adds resources, pages, widgets, navigation or markup, without editing the panel.
final class BlogPlugin implements Plugin
{
public function id(): string { return 'acme/blog'; }
public function register(AdminPanel $panel): void
{
$panel->registerResources([PostResource::class]);
}
public function boot(AdminPanel $panel): void
{
$panel->registerNavigation([
['label' => 'Blog', 'url' => '/admin/resources/posts'],
]);
}
}
Why two phases
Every plugin's register() runs before any plugin's boot().
With one phase, a plugin that inspects the panel sees whatever happened to load
first, which is decided by Composer's autoload order. Behaviour would then
depend on which unrelated packages are installed, and that is a bug people
spend days on. With two, boot() always sees a complete panel.
A broken plugin does not take the panel down
If a plugin throws while registering or booting, it is recorded and skipped. Everything else loads, and the panel opens.
This is the property worth insisting on. An admin panel is what you open when something is already wrong. A third-party package throwing must not be the reason you cannot look at your own data.
app('admin.plugins')->failures();
// ['acme/broken' => 'Undefined method Foo::bar()']
A plugin that fails to register is not booted afterwards: its own state is unknown at that point, and booting it anyway is how a half-registered plugin corrupts the panel. A plugin whose dependency failed is skipped too, which took a test to notice, because the dependency order is worked out before anything registers and a registration failure happens after.
Dependencies are declared rather than assumed, and a cycle is reported rather than resolved arbitrarily. There is no correct order for a cycle, and picking one hides the mistake.
Requires framework 0.10.3
The panel's migrations could not run on anything earlier, because Blueprint had
no unsignedBigInteger, no ipAddress and no primary. A foreign key to an
id() column had no correct type, an IP column had no type that fits IPv6, and
a pivot table had no way to declare a composite primary key. All three were
added to the framework for this.
The media library was a stub, and now is not
Every method returned a success message and did nothing:
public function upload(Request $request): Response
{
return back()->with('success', 'File uploaded successfully');
}
Which is worse than not having the feature, because it reports that it worked.
It is real now, and file upload is where an admin panel turns into remote code
execution, so it is deliberately strict. The type is detected from the bytes
with finfo, never from $_FILES['type'], which the client sets: anything
trusting that header accepts a PHP script declared as image/png. The
extension and the detected type must then agree, because either alone is not
enough. The stored filename is generated, so the client's name never becomes a
path, which removes traversal, null bytes and the double-extension trick
together.
The accepted list is an allow-list. Deny-lists lose to .php5, .phtml,
.phar, uppercase and whatever the next handler mapping adds. SVG is left off
on purpose: it is XML, it can carry script, and the browser runs it in the
origin that served it.
And Nova is gone
The framework shipped a Nova admin module that claimed the same /admin
prefix. It also did not work: its controller methods took $resourceKey while
its routes declared {resource}, so every one of its routes raised
"Unresolvable dependency" rather than rendering anything.
That collision is how it was found. An admin package installed alongside it appeared broken, and the error named a parameter belonging to something else entirely. It has been removed in framework 0.11.0, which LibAdmin now requires.