From b5c46affdca0626297447573d3cafe07c5e7db31 Mon Sep 17 00:00:00 2001 From: valgulnecron <39313199+ValgulNecron@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:38:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(cs2):=20SRCDS=5FTOKEN=20is=20a=20credential?= =?UTF-8?q?=20=E2=80=94=20store=20it=20in=20a=20Secret,=20not=20the=20CR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SRCDS_TOKEN is a Steam Game Server Login Token. It was declared type: string, so its value would be stored in PLAINTEXT in the GameServer CR — visible in 'kubectl get gameserver -o yaml', in etcd, and to anyone with read access to the resource. type: password makes the operator store it in a per-GameServer Secret and inject it via SecretKeyRef, so it never lands inline in the CR or the pod spec. The Create wizard already renders password fields as masked inputs, so the token is now supplied safely at creation time — which is what CS2 needs to launch at all. Adds a validator rule (credential-field-must-be-password) for the class: any credential-shaped config field name (PASSWORD/TOKEN/SECRET/APIKEY/ GSLT/...) that isn't type: password is an ERROR. bool/int fields are skipped — a flag named SERVER_PASSWORD_ENABLED is not a credential. Every other module already got this right; cs2 was the only leak. Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com> --- cs2/module.yaml | 2 +- cs2/template.yaml | 7 +++-- validate.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/cs2/module.yaml b/cs2/module.yaml index 13e4892..f5c46f5 100644 --- a/cs2/module.yaml +++ b/cs2/module.yaml @@ -1,7 +1,7 @@ apiVersion: gameplane.local/module/v1 name: cs2 displayName: Counter-Strike 2 -version: 1.0.1 +version: 1.0.2 game: cs2 summary: Counter-Strike 2 dedicated server (joedwards32/CS2) with Source RCON and a Metamod/CS# plugins volume. homepage: https://www.counter-strike.net/cs2 diff --git a/cs2/template.yaml b/cs2/template.yaml index 3014fdf..86929fd 100644 --- a/cs2/template.yaml +++ b/cs2/template.yaml @@ -22,7 +22,7 @@ metadata: spec: displayName: Counter-Strike 2 game: cs2 - version: 1.0.1 + version: 1.0.2 accentColor: "#f4a900" description: | Counter-Strike 2 dedicated server using @@ -121,7 +121,10 @@ spec: - name: SRCDS_TOKEN displayName: GSLT (Steam Login Token) description: Game Server Login Token from https://steamcommunity.com/dev/managegameservers. - type: string + # A Steam Game Server Login Token is a CREDENTIAL. type: password stores + # it in a per-GameServer Secret and injects it via SecretKeyRef — it never + # lands inline in the GameServer CR or the pod spec. + type: password required: true default: "" - name: CS2_HOST_WORKSHOP_COLLECTION diff --git a/validate.py b/validate.py index c0f53ac..cd8d792 100644 --- a/validate.py +++ b/validate.py @@ -723,6 +723,70 @@ def rule_puid_image_needs_fsgroup(spec: dict, cfg: dict, image_ref: str) -> list return findings +def rule_credential_fields_must_be_password(spec: dict) -> list[Finding]: + """Rule 9: credential-shaped config fields must have type: password. + + Config fields whose names look like credentials (PASSWORD, TOKEN, SECRET, + etc.) are stored in plaintext in the GameServer CR and etcd if type is not + 'password'. Only 'type: password' stores values in a per-GameServer Secret + and injects them via SecretKeyRef — the value never lands inline in the CR, + pod spec, or etcd. + + Historical bug (caught in cs2): SRCDS_TOKEN (a Steam Game Server Login Token) + was declared as 'type: string', so it would have been stored in plaintext in + the CR and visible to anyone with kubectl read access. + + ERROR: name looks credential-shaped but type is not 'password'. + Skip: type is 'bool' or 'int' (a boolean flag named 'PASSWORD_ENABLED' is not + a credential even if the name matches). + """ + config_schema = spec.get("configSchema") or [] + findings: list[Finding] = [] + + # Credential name patterns to match (case-insensitive) + credential_substrings = { + "PASSWORD", + "PASSWD", + "PASS", + "TOKEN", + "SECRET", + "APIKEY", + "API_KEY", + "AUTHKEY", + "GSLT", + "CREDENTIAL", + } + + for entry in config_schema: + if not isinstance(entry, dict): + continue + + name = entry.get("name", "") + field_type = entry.get("type", "") + + # Skip non-credential types (bool/int are never credentials) + if field_type in ("bool", "int"): + continue + + # Check if name contains any credential substring (case-insensitive) + name_upper = name.upper() + if any(substring in name_upper for substring in credential_substrings): + if field_type != "password": + findings.append( + Finding( + ERROR, + "credential-field-must-be-password", + f"config field {name!r} looks credential-shaped but " + f"type={field_type!r} (not 'password') — the value will be " + "stored in plaintext in the GameServer CR, etcd, and visible " + "in 'kubectl get gameserver -o yaml'. Set type: password so " + "the operator stores this in a per-GameServer Secret instead.", + ) + ) + + return findings + + # -------------------------------------------------------------------------- # Orchestration # -------------------------------------------------------------------------- @@ -768,6 +832,7 @@ def validate_module(spec: dict, cache: dict[str, dict]) -> list[Finding]: findings += rule_rcon_protocol(spec) findings += rule_mods_loaders_requires_versions(spec) + findings += rule_credential_fields_must_be_password(spec) return findings