Project · gcp-mcp-standalone

gcp-mcp-standalone — Security Review (2026-07-03)

type referencestatus activegcp · mcp · security · review

Scope

Full end-to-end review of the live system: Terraform infra (main.tf), Ansible (install.yml, claudebot.yml), the Firney-MCP-Broker + all connectors, the Claude Telegram bot, secrets handling, network exposure, git hygiene, and the bobswiki publish pipeline. Findings verified against the running box, not from memory.

Summary

The architecture is sound and most controls are working as designed — no open inbound ports (verified), least-privilege IAM, a well-isolated bot, secrets in Secret Manager, encrypted connector tokens. The main action item is secret rotation: several credentials were pasted in plaintext during interactive setup and must be treated as compromised. The rest are hardening / blast-radius items.

Positive controls (verified live 2026-07-03)

  • No public ingress. Deny-all firewall priority 100 on tag mcp-broker beats GCP's default permissive rules; ports 22 and 8002 confirmed closed from the public IP.
  • Broker localhost-only — binds 127.0.0.1:8002; invisible to the public IP and the tailnet (gcp-mcp-standalone/adr/0006-localhost-only-broker-bind).
  • Non-root container — broker runs as appuser (uid 1000).
  • Least-privilege IAMmcp-broker-sa holds secretAccessor on exactly the three named secrets (1:1 bindings), nothing project-wide.
  • Secrets in GCP-Secret-Manager — Tailscale key, GitHub + Notion client secrets; .env mode 600; no secret values committed in any git repo or history (checked all three: gcp-mcp-standalone, mcp-broker fork, Obsidian-Docs).
  • Bot isolationclaudebot runs unprivileged, no sudo, metadata-blocked (confirmed it cannot fetch the VM's SA token), read-only allow-list (21 rules, zero write tools), and a sender allowlist limited to one Telegram ID.
  • Encrypted at rest / gated — connector tokens Fernet-encrypted; /admin/* and /proxy/* return 401 without credentials.
  • Supply chain — Tailscale from its signed apt repo; cloudflared pinned + sha256-verified.
  • Publish pipeline — GitHub Actions jobs gate on secret presence (skip-not-fail); secrets passed via env, never echoed.

Findings

HIGH

H1 — Credentials exposed in plaintext during setup. Over the setup sessions, these were pasted into the assistant conversation (which is persisted) and must be treated as compromised: the Telegram bot token, the Notion OAuth client secret, and earlier the GitHub OAuth client secret. They work until rotated. Fix: Rotate each at source — Telegram @BotFather → Revoke current token; Notion integration → regenerate secret; GitHub OAuth app → regenerate client secret — then bump the corresponding GCP-Secret-Manager version (and the bot's telegram.env for the bot token) and redeploy. See gcp-mcp-standalone/runbooks/rotate-tailscale-key for the version-bump pattern.

MEDIUM

M1 — Broker /admin/* reachable from the public internet (key-gated, returns 401). The admin API can mint API keys and connect-tokens; if BROKER_ADMIN_KEY ever leaks, that's full connector takeover. It should not be internet-facing. Fix: A Cloudflare Access policy on /admin/*, or scope the tunnel ingress to only /proxy, /oauth, /.well-known. Defense-in-depth on top of the admin key.

M2 — No data/ durability. Connector tokens, broker API keys and inbound registrations live only on the boot disk. A VM rebuild wipes them — it has happened twice, each time breaking every integration until re-authorized (gcp-mcp-standalone/runbooks/reconnect-connectors). Fix: Put data/ on a persistent disk, or a scheduled GCS backup + restore.

M3 — GitHub connector uses the broad repo scope — full read/write to all private repos. Fix: Narrow to read-only / a fine-grained token if the usage doesn't need write.

M4 — Telegram DM policy is pairing, not allowlist. A stranger who DMs the bot still gets a pairing-code reply (not access, but a response and a minor info leak). Fix: /telegram:access policy allowlist — only known IDs get any response.

LOW

L1 — Broker token DBs world-readable (data/*.db mode 644). Fernet-encrypted and the parent dir limits reach, but tighten anyway. Fix: chmod 600 data/*.db.

L2 — Long-lived Claude token on the VM. claudebot's CLAUDE_CODE_OAUTH_TOKEN (a Claude subscription token) sits in telegram.env (600). If the box is compromised the subscription is usable. Contained by the box's isolation; rotate periodically via claude setup-token.

L3 — Cloudflare API token has no expiration; Cloudflare connector scope unverified. The Pages token (a GitHub secret) never expires; and confirm the broker's Cloudflare connector was granted read-only (this account also owns the tunnel + DNS the broker depends on). Fix: set a token expiry + rotate; verify/limit the connector's Cloudflare scopes.

L4 — Project-wide default-allow rules remain. default-allow-ssh/-rdp/-icmp (priority 65534, 0.0.0.0/0) still exist; only the tagged mcp-broker VM is shielded by the deny. Harmless with a single VM, but delete them for defense-in-depth before adding others.

  1. H1 — rotate the three exposed secrets (Telegram, Notion, GitHub) + Secret Manager bump.
  2. M1 — Cloudflare Access policy on /admin/*.
  3. M2 — persist / back up data/.
  4. M4 — switch Telegram to allowlist policy.
  5. M3 — narrow GitHub scope.
  6. L1chmod 600 data/*.db.
Compiled from wiki/projects/gcp-mcp-standalone/security-review.md · git is the source of truth