Files and server config over HTTP - #93
Conversation
#53 part 2. Files, server.properties, Java args and favourite; mods, Java install, metrics config and server creation are still IPC-only and named as such in the docs, so #53 stays open for part 3. Reading files needs `files`, not `view`. server.properties holds the RCON password, and whatever else an operator has pasted into a config; a status glance should not include it. core/serverFiles.ts already refuses to leave the server root - every entry point runs the path through the same safe() check - so a traversal comes back 400 rather than reading anything. What it does not do is stop a caller reaching the files that decide what runs: replacing a jar is code execution on the next start. That is not a reason to block it, an operator edits these constantly, but it is why `files` is its own scope and why every write is audited with its path. Deleting a file needs confirm=true. Nothing inside MSMS brings it back. A property value containing a newline is refused: in a properties file a newline smuggles in a second key, so `motd=hi\nmax-players=999` would quietly change the player cap. `updates` merges so a targeted write leaves the rest of the file alone; `raw` replaces it wholesale for callers that want that. **A real pre-existing bug, exposed by the first partial patch anyone ever sent it.** serverRegistry.updateServer does: Object.assign(s, patch, { id: s.id }) if (patch.java) s.java = { ...s.java, ...patch.java } Object.assign has already replaced s.java with the patch's partial by the time the merge runs, so the spread merged the partial into itself and dropped every key the caller did not mention. Invisible while the only caller was the desktop args editor, which always sends a complete config. POST /config/java sends a partial, and the preset vanished. Fixed by capturing s.java before the assign. Two hardenings from the same failure. buildJvmFlags did cfg.extraFlags.trim() on a hand-editable config file, so a config missing that key threw `undefined.trim()` from inside start() and surfaced as "the server never started" with no hint why - now defaulted. And the smoke's java restore ran inline, so the failed assertion that found the merge bug skipped it and left the shared dev-root fixture with a partial config, which then broke every other gate. The restore is in `finally` now, alongside a properties snapshot, because a test that can poison the fixture for its siblings is worse than the bug it found. All twelve gates pass, with the WEB gate deliberately run first to prove it no longer leaves the fixture damaged.
POST /config/java accepted any JavaArgsConfig field, including the three that decide what program MSMS runs: javaPath - spawned as the process binary customArgs - IS the whole command line when preset is 'custom' extraFlags - appended to the real command line So a `settings`-scoped API key could set javaPath to any executable on the host and the next server start would run that instead of Java. `settings` means "edit server settings", not "run arbitrary programs as the MSMS process", and remote code execution is not a settings field. Over IPC this was never a problem, which is why it existed: the caller there is the operator at the machine, who already has full filesystem access, so offering them a text box grants nothing. This PR exposed the same fields to a remote scope, and that is what turned a UI affordance into an escalation. The three are now refused over HTTP with 403 local-only-field, audited, and still editable in the desktop app. A patch mixing a safe field with a forbidden one is refused whole rather than partially applied - a caller should not have to guess which half of their request landed. Written as a denylist rather than an allowlist of safe fields on purpose: the safe set is memory numbers and booleans that will grow over time, and a new safe field being accidentally blocked is an annoyance, while a new dangerous one being accidentally allowed is this bug again. Smoke asserts each field refused with the right error, that it did not land anyway, that the refusal is audited, and that a mixed patch does not apply its safe half. All twelve gates pass.
Self reviewOne finding, and it is the sharpest in this PR: the change turned the
|
* Plugins, Java, telemetry and deregister over HTTP (#53) Closes the last IPC-only gap in the operations surface. Mods are gated on `files`, not a new `mods` scope: installing a jar writes into the server directory and deleting one removes a file from it, both of which `files` already permits outright. A separate scope would be a strict subset of one the caller must already hold — a boundary in name only. Java runtimes and telemetry retention are host-wide, so no per-server scope can express them; they are owner-session only. That has a consequence worth stating: `principalForKey` always builds `role: 'user'`, so no API key can reach them however it is scoped. The smoke proves it with a key holding every scope on every server. The telemetry patch is validated rather than cast (`shared/ops.ts`). It is persisted, so a bad value does not fail the request that set it — it fails every metrics prune afterwards, across restarts. `{ enabled: "false" }` is the sharp case: truthy, so it reads as on while the operator believes they turned it off. Server lifecycle is exposed by halves. `DELETE /api/servers/:id` deregisters and keeps every file; creating a server (a host path chosen by the caller) and deleting its files (a recursive rmSync) stay off the surface, for the same reason `javaPath` did in #93. The reversible half ships, the irreversible half does not, and the docs say so. All twelve smoke gates green. * Self-review: deregister left its alert rules behind, and under-reported Two findings on the deregister route, one of them a real parity bug. `ipc/register.ts` pairs `registry.removeServer(id, …)` with `alerts.dropServer(id)` by hand — `removeServer` itself does not drop rules, only metrics and events. The new HTTP route made the first call and not the second, so a server deregistered over the API left every rule aimed at it in the store, evaluated against a server that no longer exists, until the next launch swept them in `initAlerts`. The route now makes both calls, with a comment saying why they are separate so the next route does not repeat it. Second: `{ filesKept: true }` reads as "nothing was lost", and that is not what happened. Deregistering deletes this server's metrics folder, its event timeline and its alert rules; a rescan re-adds the folder under a new id with no history attached. The response now carries `historyDropped` and a count of `alertRulesRemoved` — a count rather than a flag, because an integration that created those rules through the API is the one caller who can be surprised by losing them. The smoke seeds a rule on the throwaway fixture and asserts both the store and the reported count, which is what caught the missing call in the first place.
#53 part 2. Files,
server.properties, Java args and favourite. Mods, Javainstall, metrics config and server creation stay IPC-only and are named as such
in the docs, so #53 remains open for part 3.
Docs extended:
docs/api-operations.md.Reading files needs
files, notviewserver.propertiesholds the RCON password, and whatever else an operator haspasted into a config. A status glance should not include that.
What the traversal guard does and does not cover
core/serverFiles.tsalready refuses to leave the server root — every entrypoint runs the path through the same
safe()check — so a traversal comes back400 path-escapewithout reading anything. Asserted for../../secrets,..\..\secretsand/etc/passwd.What it does not do is stop a caller reaching the files that decide what
runs. Replacing
server.jaris code execution on the next start;eula.txtandserver.propertieschange what the server is.That is not a reason to block them — an operator edits these constantly, and a
file editor that refuses to edit the interesting files is not a file editor. It
is the reason
filesis its own scope rather than part ofsettings, and thereason every write is audited with its path.
Deleting requires
?confirm=true. Nothing inside MSMS brings the file back.Properties: newlines are the whole risk
A property value containing a newline smuggles in a second key —
motd=hi\nmax-players=999would quietly change the player cap. Refused with400 newline-in-value, and the refusal is audited.updatesmerges, so a targeted write leaves the rest of the file alone (asserted:writing
motddoes not disturbenable-rcon).rawreplaces it wholesale forcallers that want that.
A real pre-existing bug, exposed by the first partial patch anyone sent it
Object.assignhas already replaceds.javawith the patch's partial by thetime the merge line runs. So the spread merges the partial into itself and drops
every key the caller did not mention:
Invisible for as long as the only caller was the desktop args editor, which
always sends a complete config.
POST /config/javasends a partial, and thepreset vanished. Fixed by capturing
s.javabefore the assign — the Java patchnow merges, which is what the API documents.
Two hardenings from the same failure
buildJvmFlagscrashed on an incomplete config.cfg.extraFlags.trim()on ahand-editable JSON file throws
undefined.trim()from insidestart(), whichsurfaces to the user as "the server never started" with no hint why. Defaulted.
The smoke could poison its siblings. The failed assertion that found the
merge bug returned before its inline java restore, leaving the shared
dev-rootfixture with a partial config — and then every other gate failed to start a
server for reasons unrelated to what it tests. The restore is in
finallynow,with a properties snapshot beside it. A test that can damage the fixture for its
siblings is worse than the bug it caught.
This is the second time that shape has bitten this suite (the first is recorded
in the
setr_t/nosee_tleftovers fix), which is why it got a structural fixrather than a one-line restore.
Verification
All twelve gates pass — with the WEB gate deliberately run first in the loop
to prove it no longer leaves the fixture damaged for the ones after it, and the
fixture's
javaconfig verified intact afterwards.New coverage: the scope split both ways (a
settingskey cannot read files, afileskey cannot read config), traversal refused, write→read-back→delete withthe delete gated on confirmation, the write audited with its path, a newline in a
property value refused and audited, a targeted property write not disturbing
another key, and a partial Java patch keeping both the preset and
extraFlags—the second because losing that key is what broke the launch.