security: anchor genie log parsing to the server preamble - #9
Conversation
The chat genie decides who is allowed to command the panel by reading a
player name out of a Minecraft server log line. Two problems made that
name untrustworthy, so a wish could be attributed to the wrong player and
clear the allowlist check in tick().
Unanchored carriers. All five carrier patterns in parseWish started at a
bare `\]:` separator, which the regex engine finds anywhere in the line,
including inside the message body. Message bodies are player-typed text.
On a vanilla server the real carrier usually matched first, but a server
running a chat-formatting plugin, rank prefix, shout plugin or Discord
relay emits a line shape the parser does not recognise, so the engine
skipped the unrecognised prefix and matched text the player supplied. A
player typing "]: <Owner> server ..." was then parsed as the owner. Every
carrier is now anchored to the log preamble the server itself writes, so
a name can only be read from the position the server controls.
Unattributable carrier. Public chat also accepted a bracketed [name]
shape. The server emits that shape for its own actors and for anything a
plugin, datapack or command block broadcasts, so it never proved who
typed the line. The old defence was a denylist of two pseudo-names
("[Rcon]", "[Server]"), which stops two names rather than forgery: a bare
"[Owner] server ..." line parsed as the owner. The branch is removed.
Only carriers attributable to a real player remain: <name> chat, the
whisper shapes, and the issued-server-command echo.
Also removes shell: true from the Claude spawn. The arguments are already
passed as an array, so the shell layer adds nothing and only widens what a
future interpolated argument could reach. A genie whose input is player
chat should not have a shell behind it. POSIX PATH lookup works without
one; the Windows caveat is noted in a comment rather than papered over
with an untested fallback.
Verified against the parser copied verbatim from this file: 25 cases
covering legitimate public chat, Not Secure lines, all three whisper
shapes, command echoes and varying thread tags (all still work), plus
bracket forgery and embedded-separator injection aimed at each carrier
individually (all denied). tsc --noEmit clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8ecd6153a8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| text instead. A player typing "]: <Owner> server op me" then parsed AS the | ||
| owner and cleared the allowlist check in tick(). Anchoring means a name can | ||
| only ever be read from the position the server controls. */ | ||
| const PRE = String.raw`^\[\d{2}:\d{2}:\d{2}\] \[[^\]]*\]:`; |
There was a problem hiding this comment.
Accept Forge and NeoForge log preambles
On supported Forge/NeoForge servers, latest.log lines use a preamble such as [20Aug2026 12:00:00.123] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]:, with a dated millisecond timestamp and an additional logger tag. This PRE matches only the vanilla [HH:mm:ss] [thread/level]: shape, so every public-chat, whisper, and command carrier returns null on those loaders and the genie silently stops receiving wishes. Keep the start anchor for security, but recognize the server-controlled preamble formats for the loaders supported by detect.ts.
Useful? React with 👍 / 👎.
Follow-up to the anchoring commit, from two independent code reviews. The first cut was right in principle and wrong in two ways that mattered. Anchor was too narrow. PRE pinned vanilla's log4j pattern exactly, so the genie went silently dead on Forge, NeoForge, Paper and Purpur — four of the loaders servercreate.ts builds. Forge emits two logger tags, NeoForge timestamps as [20Aug2026 12:34:56.789], Paper folds the level into one bracket. No error, no log line, the feature simply stopped. PRE now accepts any bracket timestamp plus any number of bracket tags and keeps the property that matters: the match starts at column 0, where a player cannot reach. Anchoring was bypassable through the splitter. readNewLines took a byte snapshot while the server was mid-write, so the last element of a chunk was regularly half a line and the next read began inside player-typed text. That fragment reached parseWish as its own line, and a player who typed a complete fake preamble into chat could wait for a read boundary to land in front of it. Verified against the old splitter: a cut at byte 100 of a 196-byte sample produced a forged line attributed to an allowlisted player. The trailing fragment is now held until its newline arrives; the new splitter is clean at all 195 cut positions of the same sample and still delivers every complete line in order. Also anchors the join/leave digest pattern, which still carried the original unanchored form one function below the code that removed it, so "<Trevor> ]: Owner joined the game" forged a join event. Digest only, never authorization, but it is the same defect. Windows: bare 'claude' is resolved through `where` at startup. Without a shell, CreateProcess only appends .exe, so an npm-global claude.cmd shim would fail ENOENT on every wish. Reinstating shell: true would also fix it and is exactly what the previous commit removed. The five carrier patterns are compiled once at module scope instead of per log line. Verified: 12 loader and carrier shapes parse (vanilla, Fabric, async chat thread, Forge two-tag, NeoForge date+ms, Paper folded level, millisecond and date-prefixed timestamps, Not Secure, whispers on vanilla and Forge formats, command echo on Paper). Injection through shout plugins, Discord relays, rank prefixes and plain chat stays denied, as do bracket forgery, pseudo-names and preamble-free lines. tsc --noEmit clean.
The chat genie decides who may command the panel by reading a player name out of a Minecraft server log line, and that name feeds the allowlist check in
tick(). Two problems made the name untrustworthy, so a wish could be attributed to a player who never sent it. Raised by a Crafty moderator reviewing the project; the second issue surfaced while fixing the first.Unanchored carriers
All five carrier patterns in
parseWishstarted at a bare\]:separator. The regex engine finds that anywhere in the line, including inside the message body, and message bodies are player-typed text.On a vanilla server this mostly worked out because the real carrier matched first. But a server running a chat-formatting plugin, rank prefix, shout plugin or Discord relay emits a line shape this parser does not recognise, so the engine skipped the unrecognised prefix and matched text the player supplied instead:
The name read out of that line is
Owner, notTrevor. Every carrier is now anchored to the log preamble the server itself writes, so a name can only be read from the position the server controls.Unattributable carrier
Public chat also accepted a bracketed
[name]shape. The server emits that shape for its own actors and for anything a plugin, datapack or command block broadcasts, so it never proved who typed the line. The existing defence was a denylist of two pseudo-names ([Rcon],[Server]), which stops two names rather than forgery: a bare[Owner] server ...line parsed as the owner.The branch is removed. Only carriers attributable to a real player remain:
<name>chat, the three whisper shapes, and the issued-server-command echo.shell: true
Removed from the Claude spawn, and the argument ternary simplified. The arguments are already passed as an array, so the shell layer adds nothing and only widens what a future interpolated argument could reach. A genie whose input is player chat should not have a shell behind it. POSIX PATH lookup works without one; the Windows caveat is noted in a comment rather than papered over with an untested fallback.
Verification
parseWishwas extracted verbatim from this file and driven through 25 cases.Still working:
[Not Secure]linesNow denied:
[name]broadcast forgery, and the nested/sayecho shape[Rcon]and[Server]pseudo-namestsc --noEmitclean.No changes to the RCON versus Crafty API split, the backup code, or
execution_command. A separate design decision is pending on those.