Skip to content

security: anchor genie log parsing to the server preamble - #9

Merged
vayungodara merged 2 commits into
mainfrom
security/anchor-genie-log-parsing
Aug 20, 2026
Merged

security: anchor genie log parsing to the server preamble#9
vayungodara merged 2 commits into
mainfrom
security/anchor-genie-log-parsing

Conversation

@vayungodara

Copy link
Copy Markdown
Owner

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 parseWish started 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:

[12:00:02] [Server thread/INFO]: Trevor shouted: ]: <Owner> server ...

The name read out of that line is Owner, not Trevor. 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

parseWish was extracted verbatim from this file and driven through 25 cases.

Still working:

  • public chat, including [Not Secure] lines
  • all three whisper shapes (WhisperMod, bracket-arrow, "whispers to")
  • issued-server-command echoes
  • trigger word variants and varying thread tags

Now denied:

  • bare [name] broadcast forgery, and the nested /say echo shape
  • [Rcon] and [Server] pseudo-names
  • embedded-separator injection aimed at each carrier individually, including whisper and command-echo variants
  • a full fake preamble embedded in the message body
  • lines with no preamble at all

tsc --noEmit clean.

No changes to the RCON versus Crafty API split, the backup code, or execution_command. A separate design decision is pending on those.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 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".

Comment thread server/src/services/chatgenie.ts Outdated
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}\] \[[^\]]*\]:`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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.
@vayungodara
vayungodara merged commit 2cf454d into main Aug 20, 2026
4 checks passed
@vayungodara
vayungodara deleted the security/anchor-genie-log-parsing branch August 20, 2026 16:48
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.

1 participant