Skip to content

Make plugins installable over the API - #1360

Closed
darksidemilk wants to merge 2 commits into
working-1.6from
plugin-install-server-owned
Closed

darksidemilk wants to merge 2 commits into
working-1.6from
plugin-install-server-owned

Conversation

@darksidemilk

@darksidemilk darksidemilk commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

Makes plugins installable over the API, and stops the API being able to claim an install that never happened. Those are the same problem from two sides, so they are one PR.

The problem

Install was reachable only as a management-page POST behind a session and CSRF. The REST API exposed the plugin row as a generic entity, so a client could write installed and schema directly — the two columns that record what the installer did.

PluginManagementPage::installPost() shows why that ordering matters: it sets state, then calls Plugin::installdb() to create the tables, and writes installed = 1 last.

Measured against a real server, on the bundled location plugin:

PUT /plugin/2/edit  {"installed":"1","state":"1"}   -> 200

GET system/openapi     58 schemas -> 60
                       Location, Locationassociation now present

GET /location          406  SQLSTATE[42S02]: Base table or view not found
GET /location/count    406  SQLSTATE[42S02]: ...
GET /locationassociation
                       406  SQLSTATE[42S02]: ...

Setting the column registers the plugin's classes — enough for its routes and schemas to appear in the document — while schema stays 0 and no table is ever created. The server's own OpenAPI document then advertises routes that cannot answer, and a client generated from it gets commands guaranteed to fail.

It also hides from the obvious repair: installPost() filters on installed IN ('', 0, '0'), so the Install button skips a row that already claims to be installed.

1. POST /plugin/{id}/install

The same three steps as installPost(), in the same order — activate, installdb(), then installed = 1 last. Activation blockers are refused first, the same gate the page applies, so the API is not a way around a plugin the server has a reason to refuse.

installdb() is called unconditionally rather than only when the plugin is not yet installed. Migration steps are append-only and idempotent by contract (docs/PLUGIN_SCHEMA_MIGRATIONS.md) and Schema::applyUpdates() resumes from the stored count, so calling it on an installed plugin applies only steps it has not seen — which is what the UI calls Upgrade. One route therefore installs, and brings a plugin whose code ships newer schema() steps up to date, without a drop and recreate.

That also makes it the repair for a row broken the way the one above was — which the Install button structurally cannot fix.

Permission is plugin.install, the node the web Install button checks, rather than plugin.edit: running a plugin's schema migrations is a different authority from editing its row. Declared in API_ROUTE_PERMISSIONS rather than relying on the fail-closed default in resolveApiPermission().

2. plugins.installed and plugins.schema become server-owned

One entry in Route::$serverOwnedFields. That list already drives _refuseServerOwned(), which answers 400 only when the value would change, so reading a plugin and sending the object back unmodified still works. openapi.class.php reads the same list, so the document marks both readOnly with the standard server-owned description.

state is deliberately not included. Activating is a column write and nothing else — installPost() and the Activate action both just set state = 1 — so a client writing it does the whole job rather than half of it. Activation over the API keeps working exactly as before.

Verification

Regenerated the document with spec/tools/dump-openapi.php:

/plugin/{id}/install   operationId=pluginInstall
                       responses 204/400/401/403/404/500
                       x-fog-permission=plugin.install

Plugin.installed  readOnly=True
Plugin.schema     readOnly=True
Plugin.state      readOnly=None

373 paths, up from 372. php -l clean on all three files; added lines within the 80-column style.

Not yet exercised at runtime. I have not run POST /plugin/{id}/install against a server built from this branch — the reproduction above was done against a stock server before the fix. The refusal half rides on the pre-existing _refuseServerOwned() path, unchanged here and already exercised by the task/host/user entries; the install handler is new code and deserves a real run. Happy to do that once it is deployed somewhere, or tell me if you would rather it come with a test.

Both record what the installer DID, and the installer writes them only
after it succeeded. PluginManagementPage::installPost() sets state, then
calls Plugin::installdb() to create the tables, and writes installed=1
last. Plugin::installdb() writes schema to the number of migration steps
it applied.

The generic edit route accepted both, so a client could assert an install
that never happened. Measured against a real server, on the bundled
location plugin:

  PUT /plugin/2/edit  {"installed":"1","state":"1"}   -> 200

  GET system/openapi   58 schemas -> 60
                       Location and Locationassociation now present

  GET /location        406  SQLSTATE[42S02]: Base table or view not found
  GET /location/count  406  SQLSTATE[42S02]: ...
  GET /locationassociation
                       406  SQLSTATE[42S02]: ...

So the server's own OpenAPI document advertised routes that could not
answer, and a client generated from it gets commands guaranteed to fail.
That is worse than the plugin being absent: absent is discoverable.

It also hid from the obvious repair. installPost() only calls installdb()
for plugins filtered on installed IN ('', 0, '0'), so the Install button
SKIPS a row that already claims to be installed. upgradePost() filters on
installed = 1 and does call installdb(), so Upgrade is what fixes such a
row -- which is not the button anyone reaches for, and nothing says so.

Uses the existing mechanism rather than a new one: Route::$serverOwnedFields
already drives _refuseServerOwned(), which answers 400 only when the value
would CHANGE, so reading a plugin and sending the object back unmodified
still works. openapi.class.php reads the same list, so the document now
marks both properties readOnly with the standard server-owned description.

state is deliberately NOT included. Activating a plugin is a column write
and nothing else -- installPost() and the Activate action both just set
state=1 -- so a client writing it does the whole job rather than half of
it, and that stays available over the API.

This closes the hole rather than adding a feature. Installing over REST
needs its own explicit route that calls installdb(), because creating
tables should not be a side effect of a generic PUT. Worth doing, and a
separate change.
Makes plugins installable over the API, which they were not: install was
reachable only as a management-page POST behind a session and CSRF, so a
client could set the columns but never create the tables.

The handler performs the same three steps as
PluginManagementPage::installPost(), in the same order, because the order
is the contract: activate, then Plugin::installdb() to apply the plugin's
schema migrations, then installed=1 LAST so the column only ever claims
an install that actually happened. Activation blockers are refused first,
the same gate the page applies, so the API is not a way around a plugin
the server has a reason to refuse.

installdb() is called unconditionally rather than only when the plugin is
not yet installed. Migration steps are append-only and idempotent by
contract (docs/PLUGIN_SCHEMA_MIGRATIONS.md) and Schema::applyUpdates()
resumes from the stored count, so calling it on an installed plugin
applies only steps it has not seen. One route therefore installs, and also
brings a plugin whose code ships newer schema() steps up to date -- what
the UI calls Upgrade -- without a drop and recreate.

That also makes it the repair for a row whose installed flag was set
without tables ever being created. The web Install action cannot do it:
installPost() filters on installed IN ('', 0, '0'), so it skips a plugin
that already claims to be installed.

Permission is plugin.install, the node the web Install button checks,
rather than plugin.edit: running a plugin's schema migrations is a
different authority from editing its row. Declared in
API_ROUTE_PERMISSIONS, so the fail-closed default in
resolveApiPermission() is not relied on.

Documented in openapi.class.php as a fixed route rather than left to the
generic shapes, for the same reason the upload routes are: it is an
action, not CRUD on a row.

Verified by regenerating the document:

  /plugin/{id}/install  operationId=pluginInstall
                        responses 204/400/401/403/404/500
                        x-fog-permission=plugin.install
  Plugin.installed readOnly=True   Plugin.schema readOnly=True
  Plugin.state     readOnly=None   (activation stays a plain column write)

php -l clean on all three files; added lines within the 80-column style.
@darksidemilk darksidemilk changed the title Refuse writes to plugins.installed and plugins.schema Make plugins installable over the API Aug 25, 2026
mastacontrola added a commit that referenced this pull request Aug 25, 2026
Make plugins installable over the API (fixes #1360)
@mastacontrola

Copy link
Copy Markdown
Member

Superseded by #1363, now merged to working-1.6. Your two commits went in unchanged and are attributed to you — both halves of the problem were identified correctly, and the reasoning in the commit messages is what made the rest quick to find.

I ran it against a server before merging, which was the outstanding bit. Four things came out of that:

1. The blocker gate was defeated by an unrelated key in the request body. Plugin::activationBlockers() lists plugins through Route::getList(), and listem()'s $inputoverride blanks only one of the two places it reads php://input. The other is getsearchbody(), which turns any of the class's own fields found in the body into a WHERE. On a plugin declaring fog_min 9.9.9:

POST /plugin/2/install                   -> 400 needs FOG 9.9.9
POST /plugin/2/install {"name":"ldap"}   -> 204, installed

Fixed in listem() rather than in activationBlockers(), because any gate built on a getList() was defeatable the same way.

2. installdb() is idempotent only for a manager that adopted schema(). Without one it falls back to the legacy install(), which opens with uninstall() — a DROP. The Install button's installed IN ('', 0, '0') filter was what had been holding that off, so dropping the filter deliberately meant "install an installed plugin" silently meant "empty it". A second install of such a plugin now answers 400; a first still works. taskstateedit and tasktypeedit still ship no schema().

3. The error arms sent a bare string in a body labelled application/json while the document declared the Error schema. Now setErrorMessage().

4. Leaving state writable was right, but it wanted the same blocker gate — an installed plugin deactivated because an upgrade moved past its fog_max could be switched back on with one PUT. edit() now applies it, on the transition to active only.

Also tagged the operation plugin rather than system, the way the two upload ops do. Full detail in #1363.

@mastacontrola
mastacontrola deleted the plugin-install-server-owned branch August 25, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants