Operations API: moderation, worlds and backups over HTTP - #91
Conversation
Part of #53. Covers the three groups the issue lists first; files, server config, mods and Java remain IPC-only and are named as such in the docs. Thin HTTP handlers over the existing core/* functions, the pattern the panel already proves. Own path matcher rather than widening the single-segment one: that regex is (?:\/(\w+))?$, so a nested path silently falls through to the generic 404 - the trap already documented on the alert routes - and widening it would also have swallowed the /store/... block. **A new `worlds` scope**, not folded into `players` or `settings`. Deleting or resetting a world destroys data no backup outside MSMS knows about, and an integration that only needs to read the world list should not have to be trusted with erasing one. Of the built-in roles only Operator carries it. **Command injection was the real risk here.** When a server is running, moderation is console commands, and sendCommand writes the string plus a newline to stdin - so a player name of "Steve\nstop" is two commands, the second running as the server operator. The desktop and the panel are safe by accident: they pass names from a roster the server itself reported. An HTTP caller passes whatever it likes. shared/ops.ts validates a name against an allowlist (^[A-Za-z0-9_]{3,16}$) and strips every control character from a free-text reason, before anything runs. A denylist of "characters that break a command" has to be right about all of them and only has to be wrong once. World names get the same treatment as path components: no separators, no dot-segments, no drive letters, no Windows reserved device names. Checked untrimmed on purpose - Windows drops a trailing dot or space, so `world ` and `world` name the same directory while looking like different worlds, and trimming inside the validator would both accept one string while using another and make the trailing-space rule unable to fire. **Confirmation on the four destructive calls** (backup restore/delete, world delete/reset). Not a security boundary - a caller with the scope can always pass the flag - but these are the calls an integration makes by accident, and a retry loop should not be able to erase a world. Two things deliberately not exposed: world export/import, because both take a local filesystem path and a zip upload is a different shape of endpoint; and BackupOptions.destDir, because a backups-scoped caller writing a zip anywhere on the host is a different privilege from backing up a world. Every call is audited, refusals included, attributed to the session username or key:<label> with the server it acted on. Verified with all twelve smoke gates. New coverage: the pure validators against injection and traversal inputs, a key scoped to one group refused on the others, a newline in a player name refused with the refusal audited, an unknown action 404, offline op reporting a conflict, an offline whitelist-add actually reaching whitelist.json, a real clone-then-delete round trip, and each destructive call refused without its confirmation. Three bugs found by the smoke while writing it, all mine: the world validator trimmed before applying its own trailing-space rule so that rule could never fire; the world DELETE read `confirm` from a body that a DELETE does not have, making world deletion unreachable; and the world assertions were wrapped in `if (worldNames.length)`, so on a fixture with no world they silently tested nothing - the gate now seeds one and fails if it does not register.
**restoreBackup could run against a running server.** core/worlds.ts calls assertStopped() before every destructive operation; restoreBackup never did. Extracting over a live world corrupts it - the server holds region files open and writes its in-memory state back on its own schedule, so a restore part-way through leaves a mix of old and new chunks and is then overwritten by the save. The only thing standing in the way was a sentence in the desktop dialog: "This overwrites current files with the backup. Stop the server first." Advice, not a check - and an API caller never reads it. This PR made restore reachable over HTTP, which turns advisory into nothing at all. Guarded in core, where the other destructive ops guard, so both surfaces get it and the route's existing error mapping already answers 409. The desktop's advice is now enforced rather than suggested. Proved in MSMS_SMOKE, which is the only gate with a genuinely running server: create a backup, attempt a restore, require 'server-running'. That sits next to the existing world-delete guard assertion, which exists for the same reason. **A dead route.** The ops matcher had its own `GET /players`, but /api/servers/:id/players is a single path segment, so the matcher above claims it and returns first. A second handler that never runs is worse than no handler: it reads like the authoritative one. All twelve smoke gates pass.
Self reviewTwo findings, one of them a data-loss hazard this PR would have widened. Both 1.
|
Part of #53 — the three operation groups the issue lists first. Files, server
config, mods and Java stay IPC-only and are named as such in the docs, so the
issue stays open for part 2.
New reference doc:
docs/api-operations.md.The real risk in this slice: command injection
When a server is running, moderation is console commands, and
sendCommandwrites the string plus a newline to the server's stdin:
So
POST /players/op { player: "Steve\nstop" }would be two commands, the secondrunning as the server operator. The desktop app and the web panel are safe by
accident — they pass names from a roster the server itself reported. An HTTP
caller passes whatever it likes.
shared/ops.tsvalidates before anything runs:playeragainst an allowlist —^[A-Za-z0-9_]{3,16}$reasonstripped of every control character (C0, DEL, NEL,U+2028/U+2029) and capped
Allowlist rather than denylist on the name because a denylist of "characters that
break a command" has to be right about all of them and only has to be wrong once.
The reason genuinely is free text — "griefing spawn, 3rd warning" is a real ban
reason — so there it is a strip, not a reject.
World names get the path-component treatment: no separators, no
./.., nodrive letters, no Windows reserved device names.
A new
worldsscopeNot folded into
playersorsettings. Deleting or resetting a world destroysdata that no backup outside MSMS knows about, and an integration that only needs
to read the world list should not have to be trusted with erasing one. Of the
built-in roles only Operator carries it.
Confirmation on the four destructive calls
backup.restore,backup.delete,world.delete,world.resetneedconfirm: true(or?confirm=trueon aDELETE) on top of the scope.Explicitly not a security boundary — a caller with the scope can always pass
the flag. It is there because these are the calls an integration makes by
accident: a retry loop, a mis-set variable, an example copied without reading it.
Deliberately not exposed
upload/download is a different shape of endpoint than the rest of this surface.
Better tracked separately than half-done here.
BackupOptions.destDir— an arbitrary filesystem path. Abackups-scopedcaller able to write a zip anywhere on the host is a different privilege from
being able to back up a world.
Endpoints
Own path matcher rather than widening the existing single-segment one: that regex
is
(?:\/(\w+))?$, so a nested path silently falls through to the generic 404 —the trap already documented on the alert routes — and widening it would also have
swallowed the
/store/...block.Verification
All twelve smoke gates pass.
Asserted: the pure validators against injection and traversal inputs; a key
scoped to one group refused on the other two; a newline in a player name refused
and the refusal audited; an unknown action
404; offlineopreporting409rather than pretending to work; an offline
whitelist-addactually reachingwhitelist.jsonby uuid; a real clone-then-delete world round trip; eachdestructive call refused without its confirmation; a backup id from elsewhere
404; and every operation entry attributed to the key with its server.Three bugs the smoke caught, all mine
that rule could never fire — and the route then used the untrimmed name, which
is the exact hazard the rule was written for. It no longer trims.
DELETEreadconfirmfrom a body aDELETEdoes not have, so?confirm=truewas ignored and world deletion was unreachable.if (worldNames.length)— on a fixturewith no world they silently tested nothing. The gate now seeds a world and
fails if it does not register as one.