fix(security): close findings from a full security audit - #24
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens BeaconMCP against several security-audit findings across OAuth redirect validation, chat-tool approval gating, Proxmox API path construction, secrets handling (file permissions + audit redaction), and BMC/IPMI credential exposure. It also adds regression tests and CI/linting to prevent these issues from resurfacing.
Changes:
- Expands the dashboard chat confirmation gate to cover additional code-execution/destructive tools and adds arg-shape-based gating for read-or-write tools.
- Tightens OAuth redirect trust (loopback host parsing), adds Proxmox API path-segment validation, and improves secret-handling (audit redaction, ipmitool password via env, owner-only DB/.env permissions, systemd hardening).
- Adds comprehensive regression tests plus CI (ruff + pytest) and supporting documentation updates.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_utils.py | Removes unused import. |
| tests/test_totp_replay.py | Adds replay-protection tests for TOTP verification. |
| tests/test_token_store.py | Adds TokenStore behavior + thread-safety tests. |
| tests/test_snapshots.py | Removes unused pytest import in snapshot tool tests. |
| tests/test_security_hardening.py | Adds security regression suite covering redirect validation, path guards, confirmation gate, verify_tls, db perms, and audit redaction. |
| tests/test_proxmox_client.py | Adds concurrency tests for ProxmoxClient connection-cache locking. |
| tests/test_integration.py | Fixes several string formatting / argument issues in integration test output. |
| tests/test_dashboard_chat.py | Removes unused import and normalizes inline imports. |
| tests/test_cloudflare_diagnostic.py | Adds tests for Cloudflare-specific 401 diagnostic hint behavior. |
| tests/test_bmc_registry.py | Updates registry expectations to use RedfishBackend and adds unreachable-backend error-path test. |
| tests/test_bmc_ipmi.py | Verifies IPMI password never appears on argv; asserts env-based passing. |
| src/beaconmcp/wizard.py | Ensures .env produced/updated by beaconmcp init is chmod 0600. |
| src/beaconmcp/server.py | Wraps all tools to run sync functions in worker threads while preserving metrics/audit emission. |
| src/beaconmcp/proxmox/system.py | Removes unused import. |
| src/beaconmcp/proxmox/client.py | Adds API path-segment validation and connection-cache locking. |
| src/beaconmcp/dashboard/db.py | Enforces owner-only permissions for dashboard.db and WAL/SHM sidecars. |
| src/beaconmcp/dashboard/chat.py | Expands confirmation gating and adds arg-shape-based gating + dry_run bypass. |
| src/beaconmcp/dashboard/app.py | Fixes _render call argument passing for overview/usage routes. |
| src/beaconmcp/config.py | Parses and surfaces bmc.devices[].verify_tls correctly. |
| src/beaconmcp/bmc/redfish.py | Minor cleanup (removes unused import). |
| src/beaconmcp/bmc/ipmi.py | Switches ipmitool password passing from argv (-P) to env (-E + IPMI_PASSWORD). |
| src/beaconmcp/auth.py | Fixes redirect-uri loopback trust by parsing hostname; adds TOTP replay protection; improves TokenStore locking. |
| src/beaconmcp/audit.py | Expands redaction key set for OAuth/session/token material. |
| src/beaconmcp/main.py | Adds Cloudflare-edge diagnostic hint in unauthorized bodies and preserves OAuth discovery headers. |
| README.md | Updates security guidance and Cloudflare deployment note. |
| pyproject.toml | Adds anyio dependency, dev extras, and ruff/pytest configuration. |
| docs/troubleshooting.md | Adds Cloudflare-specific 401/403 troubleshooting entry. |
| docs/dashboard.md | Updates and expands “dangerous tool” confirmation documentation. |
| docs/cloudflare.md | Adds detailed Cloudflare deployment guidance (WAF/Access/caching). |
| deploy/install.sh | Ensures .env is chmod 0600 during install. |
| deploy/beaconmcp.service | Adds UMask=0077 and additional systemd hardening flags. |
| beaconmcp.yaml.example | Documents and exemplifies verify_tls for Redfish-capable BMCs. |
| .github/workflows/ci.yml | Adds CI workflow running ruff + pytest on Python 3.11/3.12. |
Comments suppressed due to low confidence (1)
src/beaconmcp/proxmox/client.py:47
- The exception message hard-codes the allowed character-class as
[A-Za-z0-9._@+-]*. If_SAFE_PATH_SEGMENTis updated to allow underscores (to match Proxmox reality), update this message too so operators see accurate guidance when a segment is rejected.
raise UnsafePathSegmentError(
f"illegal Proxmox API path segment {part!r} in {path!r}: "
"segments must match [A-Za-z0-9][A-Za-z0-9._@+-]* "
"(no slashes, no '..')"
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Deliberately excludes `/`, `..`, `.` and a leading `_` -- the latter because | ||
| # `api_call` walks the path with getattr() and proxmoxer's __getattr__ refuses | ||
| # (or, worse, resolves) dunder/private attribute names. | ||
| _SAFE_PATH_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@+-]*$") | ||
|
|
There was a problem hiding this comment.
Not reproducible -- _ is already in the class. [A-Za-z0-9._@+-] is dot, underscore, at, plus, hyphen; it reads as if the underscore were missing, but it is the second character.
>>> from beaconmcp.proxmox.client import _SAFE_PATH_SEGMENT as R
>>> [(s, bool(R.match(s))) for s in ("pre-upgrade_2024", "local_lvm", "snap_1", "_leading")]
[("pre-upgrade_2024", True), ("local_lvm", True), ("snap_1", True), ("_leading", False)]
So underscores are accepted anywhere after the first character, and only a leading underscore is rejected -- deliberately, because api_call walks the path with getattr() and a leading _ would reach proxmoxer's private attributes rather than building a URL segment.
test_legitimate_paths_are_not_rejected in tests/test_security_hardening.py already pins exactly this with nodes/pve1/lxc/200/snapshot/pre-upgrade_2024/rollback, and it passes -- had the class actually excluded _, that assertion would be red rather than green.
The error message is accurate as written for the same reason, so no change here.
A full read of src/ (auth, dashboard, proxmox, ssh, bmc, deploy) turned up one hole that mattered and a set of smaller ones. The important one: the chat's human-approval gate covered ssh_run / proxmox_run but not proxmox_write_file, so an instruction injected through a log line, a config file or a web-search result could write ~/.ssh/authorized_keys on a guest with no modal -- exec through the front door was gated, exec through the side door was not. - chat: _NEEDS_CONFIRMATION now covers guest-file writes and the destructive tools (vm_bulk_action, vm_stop/restart/migrate, snapshot rollback/delete, backup_restore, bmc_power_off/reset). New _CONFIRM_WHEN_ARG_PRESENT gates proxmox_vm_config only when `updates` is set; dry_run=True calls and exec_id-only polling still skip the modal. - bmc: BMCDevice.verify_tls was declared but never parsed from the YAML, so `verify_tls: true` was silently ignored and every Redfish call ran with certificate verification off -- while sending the BMC admin password in Basic Auth on each request. Parse it, surface it in validate-config, document it in beaconmcp.yaml.example. - bmc: stop passing the BMC password as `ipmitool -P <pw>`; argv is world-readable through /proc/<pid>/cmdline. Use `-E` + IPMI_PASSWORD. - dashboard: chmod 0600 dashboard.db and its WAL sidecars. clients.json and tokens.db were locked down already, but this file stores mcp_bearer in plaintext and was left at the process umask. - deploy: chmod 600 the .env written by install.sh and by `beaconmcp init`; add UMask=0077 / NoNewPrivileges / PrivateTmp to the systemd unit. - auth: is_trusted_redirect_uri matched loopback callbacks by string prefix, so http://localhost:1@attacker.example/cb passed. Resolve the parsed hostname instead. /oauth/authorize caught this downstream, the DCR register endpoint did not. - proxmox: every endpoint is an f-string with caller-supplied path segments (snapname, storage, archive). Reject anything not shaped like a plain segment before the request leaves the process. - audit: redact session_id, code_verifier, totp_secret, session_key, access_token and private_key. - dashboard: /app/overview and /app/usage passed _render's arguments in the wrong order and 500'd on every request. Tests: 341 passed.
f9a67b2 to
70e2a67
Compare
…gate The new dry_run escape in _tool_call_requires_confirmation was applied to every gated tool, but only the three snapshot tools declare the parameter. FastMCP validates arguments with a plain pydantic model, so extra='ignore' silently drops an undeclared dry_run server-side: ssh_run with command="rm -rf /" and dry_run=True skipped the modal and then ran for real. That is a bypass of the pre-existing ssh_run / proxmox_run gate, and it is reachable through exactly the injected instruction the gate exists to stop. Restrict the escape to _DRY_RUN_AWARE. Two tests pin it: the bypass shape on six tools that lack the parameter, and a check that every allow-listed tool really declares dry_run so the list cannot drift. Also skip the dashboard.db permission test on Windows, where os.chmod only toggles the read-only bit.
|
Relu en entier. Le gros du travail tient : la regex de segments Proxmox ne casse aucun chemin réellement construit (j'ai vérifié que les UPID et les Un point bloquant par contre, que j'ai corrigé directement sur la branche (dd0cfd6). L'échappatoire Donc Le correctif restreint l'échappatoire à un J'ai aussi mis un skip Windows sur Deux remarques mineures, rien qui bloque :
Merci pour l'audit, la partie |
The example config gained a third BMC device to document verify_tls, and
the config job enumerates the template's ${VAR} references explicitly, so
validate-config rejected the unset one and the job went red.
Showdown76py
left a comment
There was a problem hiding this comment.
Le bypass dry_run est corrigé et figé par des tests (dd0cfd6), le job config a récupéré son stub d'env (6e56320). Suite complète verte en local sous mcp 1.27 : 346 passés, ruff clean. Le job test reste rouge à cause de mcp 2.0.0 qui a supprimé mcp.server.fastmcp, cassure déjà présente sur main et traitée à part.
Showdown76py#31 split the README into docs/, which collided with this branch's edit to the confirmation paragraph in the old monolithic file. Take the split README as-is and move the substance to its new home in docs/security.md: the gate now covers the write and destructive tools, not just ssh_run and proxmox_run. Also correct docs/dashboard.md, which still described dry_run as exempting any gated tool. That was the behaviour dd0cfd6 removed, so the sentence now names the three snapshot tools and says why the argument alone is not evidence.
Why
A full read of
src/-- auth, dashboard (routes + chat engine + templates + JS), proxmox, ssh, bmc, plusdeploy/and the Dockerfile -- rather than a diff review of one branch. Most of the surface held up: SQL is parameterised everywhere,client_idisolation is correct on conversations / usage / tokens / DCR slugs, PKCE S256 is mandatory, the TOTP replay guard from #23 is sound, Jinja autoescaping is on, and the hand-rolled markdown renderer inchat.jsescapes before it formats. Noeval/pickle/yaml.load/shell=True, no hardcoded secrets.What it did turn up was one hole that mattered and a set of smaller ones, mostly in seams between PRs that each did their part correctly.
The one that mattered: the chat's human-approval gate (introduced with the unified run tools, tightened in #9) covered
ssh_runandproxmox_run-- but notproxmox_write_file. Writing a guest file is code execution in one hop:~/.ssh/authorized_keys,/etc/cron.d/x. The model's input is not trusted -- a log line pulled byproxmox_read_file, a config file, or a Google Search result can carry an injected instruction -- so a single detoured turn could plant a key on a production VM with no modal, while the equivalentssh_runcall was blocked. Exec through the front door was gated; exec through the side door was not.The rest are seams:
BMCDevice.verify_tlswas added with Epic B: Universal Hardware & Redfish #17 (Epic B: Universal Hardware & Redfish) andRedfishBackendreads it -- butConfig._build()never parsed it from the YAML.verify_tls: truewas silently a no-op, so every Redfish call ran with certificate verification off while sending the BMC admin password in Basic Auth on each request.clients.jsonandtokens.dbto 0600.dashboard.dbwas not in that pass, and it storesmcp_bearerin plaintext (onlyclient_secretis AES-GCM encrypted). Any local user could lift a live bearer out of it.http://localhost:1@attacker.example/cbpassedis_trusted_redirect_uri./oauth/authorizere-checked the parsed hostname downstream and caught it; the DCR register endpoint added later validated only through that helper, and did not.Changes
Confirmation gate (
dashboard/chat.py)_NEEDS_CONFIRMATIONnow covers the code-execution primitives (proxmox_write_filealongside the existing exec and transfer tools) and the destructive ones (vm_bulk_action,proxmox_vm_stop/_restart/_migrate,proxmox_snapshot_rollback/_delete,proxmox_backup_restore,bmc_power_off,bmc_power_reset)._CONFIRM_WHEN_ARG_PRESENTgatesproxmox_vm_configonly whenupdatesis set, so reading a config does not raise a modal.dry_run=Truecalls andexec_id-only polling still skip it.docs/dashboard.mdand the README security section rewritten to match.BMC
verify_tlsparsed for real, surfaced invalidate-config, documented with a worked example inbeaconmcp.yaml.example. Default staysfalse-- BMCs ship self-signed.ipmitoolno longer receives the password as-P <pw>; argv is world-readable through/proc/<pid>/cmdlinefor the duration of the call. Now-E+IPMI_PASSWORDin the child environment.File permissions
dashboard.dband its-wal/-shmsidecars chmod 0600, re-asserted on each connection because WAL recreates the sidecars per worker thread.chmod 600on the.envwritten bydeploy/install.shand appended bybeaconmcp init-- it holds every Proxmox token, BMC password, SSH password and the session key.UMask=0077,NoNewPrivileges=true,PrivateTmp=trueon the systemd unit, to close the window before each explicit chmod lands.Input validation
is_trusted_redirect_uriresolves the parsed hostname for loopback callbacks instead of prefix-matching the string. Custom schemes (vscode://,cursor://) stay prefix-matched -- there the scheme is the authority. Plain-HTTP callbacks no longer fall through to the origin allowlist.ProxmoxClient.api_callrejects path segments that are not plain segments before issuing the request. Every endpoint is an f-string with caller-supplied values spliced in (.../snapshot/{snapname}/rollback,.../storage/{storage}/content);nodewas already constrained to a configured node,snapname/storage/archivewere not.Smaller
audit._REDACT_KEYSgainssession_id,code_verifier,totp_secret,session_key,access_token,private_key,passwd,client_secret_hash. Non-secret identifiers such asclient_idstay readable./app/overviewand/app/usagepassed_render's arguments in the wrong order and 500'd on every request. Fixed in passing.Known and left alone
Deliberately not changed here, each for a stated reason:
/metricsunauthenticated -- documented in-code as network-ACL-controlled, no label leaks a secret, and changing it breaks existing scrapes.beaconmcp.serviceruns asUser=root-- needed to write/opt/beaconmcpas things stand. Moving to a dedicated user is a permissions migration, not a patch.mcp_bearerstored in plaintext indashboard.db-- mitigated by the 0600 change above; encrypting it likeclient_secretis a schema change._issuer()trustsHost/X-Forwarded-Host-- OAuth discovery URLs are poisonable in theory, but the client sets its own Host andserver.allowed_hostsalready covers/mcp. A real fix anchors the issuer on explicit config._validate_authorize_paramsrejects custom schemes (parsed.scheme not in ("https", "http")), which makes thevscode:///cursor://entries in the trust list dead. That is a functional bug; fixing it loosens security, so it is flagged rather than changed.Related
Builds on the security work in #11 (SSH host-key verification, per-IP auth rate limiting), #12 (structured audit log), #22 (owner-only
clients.json/tokens.db, audit redaction) and #23 (thread-safeTokenStore, TOTP replay protection). Fixes seams left by #17 (verify_tls) and #22 (dashboard.db).Tests
341 passed(297 before, +44 added),ruff check src/ tests/clean, Python 3.12.New
tests/test_security_hardening.pypins each finding so a refactor that reopens one fails there: loopback prefix evasion, traversal-shaped API path segments (with the legitimate shapes real tools build asserted as still-accepted), the confirmation gate in both directions,verify_tlsreachingRedfishBackend,dashboard.dbpermissions, and audit redaction.tests/test_bmc_ipmi.pygains an assertion that the BMC password never appears inargv.