Security Model
mcp-unifi is designed to run on a trusted home or homelab LAN, behind your existing network boundary. This page covers the threat model, the hardening that’s baked into the image, and how to verify the supply chain.
Threat model
Section titled “Threat model”- Trusted boundary: starting in v0.9.0, the HTTP transport requires a bearer token on every request (see the Authentication guide). The server refuses to start without tokens by default. Stdio transport remains unauthenticated by design — the parent process owns the security boundary. Even with auth on, run the server inside a trusted LAN segment or behind a Tailscale ACL: defence in depth.
- Local-network only: the server talks to your UniFi gateway over the local API (X-API-Key header to
https://<gateway>/proxy/network/...). It does not call out to any Ubiquiti cloud endpoint, does not require a UI account, and does not require Site Manager / Cloud Console enrollment. - Self-signed gateway certs: most home gateways present a self-signed certificate.
UNIFI_VERIFY_SSLdefaults tofalsefor that reason. Set it totrueonce you’ve installed a real certificate.
Read-only mode
Section titled “Read-only mode”Set MCP_UNIFI_READONLY=true and the server becomes structurally incapable of changing anything. Every tool that mutates is dropped from tools/list and refused on tools/call; read tools behave exactly as before.
Both halves matter. Hiding a tool from the listing only removes the suggestion — a client that hard-codes a tool name, replays a cached manifest, or simply guesses would still reach the tool body. The call-time refusal is the control; the listing filter is what keeps a model from trying in the first place. The refusal is the standard {"error": ..., "stub_mode": ...} envelope every other tool failure uses, so no caller needs a second error path.
This is defense in depth on top of a read-only UniFi API key, not a substitute. Give the server a read-only key and the controller refuses writes; add this setting and the server never attempts one. Use both. Reach for the flag when the server is exposed to an agent you are still evaluating, during an audit or incident review where nothing should change, or on a second instance you keep pointed at production for reads while a separate instance holds write credentials.
How tools are classified
Section titled “How tools are classified”Each tool declares its own classification at registration:
@mcp.tool()@audited("list_networks", mutates=False)async def list_networks(...) -> str: ...mutates is a required argument, and registration fails if a tool has not declared it — the server refuses to start rather than let an unclassified tool default into being callable. A test enumerates every registered tool and fails CI on the same condition, so a tool added later cannot silently slip through.
Classification is deliberately not derived from tool names. Twelve mutating tools carry none of the write-shaped prefixes a name-based gate would match:
confirm_destructive_action, restore_config, block_client, unblock_client, quarantine_client, reconnect_client, restart_device, rename_device, locate_device, toggle_traffic_rule, toggle_traffic_route, trigger_speedtest
The first is the reason the shortcut is unusable: confirm_destructive_action is the tool that executes a queued delete. A prefix-matching gate would ship a “read-only” server that still commits deletions.
Judgment calls, recorded so they are not re-litigated:
trigger_speedtestmutates. It changes no configuration, but it makes the gateway saturate the WAN for 30-60 seconds on demand. Read-only has to mean the server cannot make the hardware do work, not just that it cannot edit config.get_speedtest_resultsstays available.locate_devicemutates. It flashes a physical LED until something turns it off. Changing what the hardware is doing in the room is not a read.rename_devicemutates. Cosmetic, but persisted, and it is the label every other tool’s output shows.backup_configis a read. It is a fan-out of GETs returned to the caller; it writes nothing to the controller. Taking a backup is exactly what you want to still be able to do in read-only mode.get_console_healthandget_console_firmwareare reads even though the console path may POST a login. Authenticating is how the server reads UniFi OS at all, andget_console_healthis the one tool that still answers when the Network application is down.- Every
delete_*tool mutates, including its preview phase. Preview-then-confirm is an interlock against mistakes, not an access control.
Secret handling
Section titled “Secret handling”UNIFI_API_KEYand all per-controllerapi_keyvalues are wrapped in Pydantic’sSecretStr. Reading the cleartext requires.get_secret_value();repr()and structured logging never echo the raw key.- The startup
safe_repr()log line includes only anapi_key_set: true/falseboolean per controller, never the key itself. - One canonical pattern list (
mcp_unifi.redaction.SENSITIVE_KEY_PATTERNS) covers three emitters: the structured logger, the audit log, and tool responses. It matchesapi_key,passphrase/x_passphrase,password/x_password,secret,token,psk,pre_shared_key/preshared_key, andprivate_key/privkey. - Read paths that return controller records replace those values with
[REDACTED]before the response leaves the server, in stub mode and real mode alike: WLANs (x_passphrase), networks (WireGuardx_private_key/x_preshared_key, site-to-sitex_ipsec_pre_shared_key, RADIUSx_secret), dynamic DNS (x_password), the guest portal, Teleport, and Access credentials.backup_configsubstitutes its own<redacted-on-backup>sentinel sorestore_configcan recognise it and force the restored resource toenabled=false. - References are deliberately not redacted.
radiusprofile_idnames a profile; it is not the shared secret, and redacting it would break the tools that resolve it. - The audit log applies the same scrub to tool kwargs before writing.
Container-level hardening (baked into the image)
Section titled “Container-level hardening (baked into the image)”The published image (ghcr.io/pete-builds/mcp-unifi) contributes:
- Non-root: runs as UID 1000, no shell, no home directory.
- Hash-pinned deps: Python dependencies installed with
pip --require-hashesfrom a hash-lockedrequirements.lock. The base image is pinned by digest. - Trivy scan: CI fails the build on any HIGH or CRITICAL vulnerability finding. Current status: zero findings.
- Multi-arch:
linux/amd64andlinux/arm64from one release.
Runtime hardening (applied by Compose or Helm)
Section titled “Runtime hardening (applied by Compose or Helm)”The following protections come from the runtime configuration, not the image itself. A plain docker run without these flags does not apply them — replicate them yourself if you deploy that way:
- Read-only root filesystem:
read_only: truein compose /securityContext.readOnlyRootFilesystem: truein Helm./tmpistmpfsfor ephemeral writes (audit log, runtime caches). no-new-privileges:security_opt: no-new-privileges:truein compose /securityContext.allowPrivilegeEscalation: falsein Helm. Prevents setuid escalation paths.- Capabilities dropped:
securityContext.capabilities.drop: [ALL]in Helm; addcap_drop: [ALL]in compose for the same posture. - NetworkPolicy (Kubernetes): the chart ships an optional
NetworkPolicytemplate so operators can pin ingress and egress explicitly.
Supply-chain provenance
Section titled “Supply-chain provenance”cosign keyless signature
Section titled “cosign keyless signature”Every published image is signed via cosign keyless OIDC. The signing identity is the GitHub Actions workflow that built the image; verification confirms the image came from that workflow on this repo, not from someone with a stolen GHCR token.
cosign verify ghcr.io/pete-builds/mcp-unifi:latest \ --certificate-identity-regexp 'https://github.com/pete-builds/mcp-unifi' \ --certificate-oidc-issuer https://token.actions.githubusercontent.comA successful verification prints the signature payload (certificate subject, issuer, GitHub workflow ref). Failure means the tag does not have a valid signature from this repo.
Each release attaches a CycloneDX SBOM generated by Syft:
# Download from the GitHub release assetscurl -L -o sbom.cdx.json \ https://github.com/pete-builds/mcp-unifi/releases/download/v0.5.0-rc.2/sbom.cdx.jsonThe SBOM lists every Python package, OS package, and file digest baked into the image. Useful for vulnerability scanning, license auditing, or comparing across releases.
Provenance attestation
Section titled “Provenance attestation”The release workflow uses docker/build-push-action with provenance: true, attaching a build attestation to each image. Inspect it with:
docker buildx imagetools inspect \ ghcr.io/pete-builds/mcp-unifi:latest \ --format '{{ json .Provenance }}'What’s intentionally not in scope
Section titled “What’s intentionally not in scope”- No per-tool RBAC. Per-module scoping is supported (
client_id:token:module1|module2inMCP_UNIFI_AUTH_TOKENS; see the Authentication guide) so a client can be restricted to Network, Protect, or Access. Finer per-tool scopes are a v1.x decision. - No token rotation API. Rotate by editing
MCP_UNIFI_AUTH_TOKENSand restarting. - No rate limiting.
- No remote-control of the audit log. The log is local. Ship it to your SIEM with whatever you already use (Fluent Bit, syslog forwarder, etc).
- No tenant isolation. One server instance = one tenant. Multi-tenant SaaS deployment is a post-v1.0 question.
For vulnerability reports, see SECURITY.md.