Skip to content

Files and server config over HTTP - #93

Merged
CaYatur merged 2 commits into
mainfrom
feat/api-files
Jul 28, 2026
Merged

Files and server config over HTTP#93
CaYatur merged 2 commits into
mainfrom
feat/api-files

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner

#53 part 2. Files, server.properties, Java args and favourite. Mods, Java
install, 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, not view

server.properties holds the RCON password, and whatever else an operator has
pasted into a config. A status glance should not include that.

What the traversal guard does and does not cover

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 path-escape without reading anything. Asserted for ../../secrets,
..\..\secrets and /etc/passwd.

What it does not do is stop a caller reaching the files that decide what
runs. Replacing server.jar is code execution on the next start; eula.txt and
server.properties change 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 files is its own scope rather than part of settings, and the
reason 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=999 would quietly change the player cap. Refused with
400 newline-in-value, and the refusal is audited.

updates merges, so a targeted write leaves the rest of the file alone (asserted:
writing motd does not disturb enable-rcon). raw replaces it wholesale for
callers that want that.

A real pre-existing bug, exposed by the first partial patch anyone sent it

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 line runs. So the spread merges the partial into itself and drops
every key the caller did not mention:

result: {"maxMemoryMB":3072}   <- preset, flags, jarFile all gone

Invisible for as long as 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 — the Java patch
now merges, which is what the API documents.

Two hardenings from the same failure

buildJvmFlags crashed on an incomplete config. cfg.extraFlags.trim() on a
hand-editable JSON file throws undefined.trim() from inside start(), which
surfaces 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-root
fixture 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 finally now,
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_t leftovers fix), which is why it got a structural fix
rather 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 java config verified intact afterwards.

New coverage: the scope split both ways (a settings key cannot read files, a
files key cannot read config), traversal refused, write→read-back→delete with
the 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.

#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.
Copilot AI review requested due to automatic review settings July 28, 2026 09:31
@CaYatur CaYatur added enhancement New feature or request area:api External integration API (REST/WebSocket) labels Jul 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
@CaYatur

CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Self review

One finding, and it is the sharpest in this PR: the change turned the
settings scope into arbitrary code execution.
Fixed in 55f7cd3.

POST /config/java accepted the fields that decide what binary runs

const b = (await readBody(req)) as Partial<JavaArgsConfig>
const updated = registry.updateServer(id, { java: b as JavaArgsConfig })

Three of those fields are not settings, they are the command line:

// processManager.start()
const javaInfo = await detectJava(server.java.javaPath || getConfig().defaults.javaPath)
const javaBin  = javaInfo?.path ?? javaExecutable(server.java.javaPath)
  • javaPath is spawned as the process binary. Point it anywhere and the next
    server start runs that instead of Java.
  • customArgs is the whole command line when preset is custom.
  • extraFlags is appended to the real one.

So a settings-scoped API key — a scope whose UI label is literally "Edit
settings" — was remote code execution as the MSMS process, waiting for the next
restart.

Over IPC this was never a problem, which is exactly why it existed. There the
caller is the operator sitting at the machine, who already has full filesystem
access; offering them a text box grants nothing they did not have. What my PR did
was expose the same fields to a remote scope, and that is the whole difference.
It is worth naming that shape because it is going to recur: "safe because the
caller is local" stops being true the moment an HTTP route reuses the function.

Now refused with 403 local-only-field, audited, and still editable in the
desktop app.

Two deliberate details:

  • A mixed patch is refused whole. { minMemoryMB: 512, javaPath: "..." }
    applies neither. A caller should not have to work out which half of their
    request landed.
  • Denylist, not an allowlist of safe fields. The safe set is memory numbers
    and booleans that will grow; a new safe field being accidentally blocked is an
    annoyance, a new dangerous one being accidentally allowed is this bug again.

Checked and clean

  • Large-file reads are not a DoS. readTextFile returns
    { content: '', binary: true } past MAX_TEXT before reading, so a caller
    cannot pull a multi-gigabyte region file into a JSON response.
  • Writes are bounded by readBody's 256 KB cap.
  • config GET returns the absolute server path to a settings-scoped
    caller. Left in: it is the same string the desktop shows, and every operation
    in this surface is already scoped to that one server.

Reviewed and deliberately left alone

  • files can replace server.jar. Not blockable without making the file
    editor useless — an operator edits jars, eula.txt and server.properties
    constantly. It is why files is its own scope and why every write is audited
    with its path.
  • Reading needs files rather than view. Deliberately stricter:
    server.properties holds the RCON password.
  • POST /config/properties accepts raw, replacing the file wholesale. Same
    reasoning as file writes, and the newline check would be pointless there — the
    caller is explicitly supplying a whole file.

Verification

All twelve gates, WEB run first to prove it leaves the fixture intact.

@CaYatur
CaYatur merged commit 95f8403 into main Jul 28, 2026
1 check passed
@CaYatur
CaYatur deleted the feat/api-files branch July 28, 2026 09:35
CaYatur added a commit that referenced this pull request Jul 28, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api External integration API (REST/WebSocket) enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants