# Security Policy ## Supported Versions | Version | Supported | | ------- | --------- | | latest | ✅ | ## Threat Model Understanding what ha-mcp does and doesn't defend against helps you write accurate reports and helps us triage quickly. ### MCP clients are trusted principals An authenticated MCP client (one that has the secret URL or completed the OAuth flow) is a **trusted principal**. ha-mcp does not attempt to defend against a malicious or compromised MCP client — that is equivalent to defending against a malicious user who has your Home Assistant long-lived access token. Consequences: - Prompt injection that reaches an MCP client is the **client's responsibility** to defend against. If the LLM embedded in a client is tricked into calling a tool it shouldn't, that is a client-side control issue, not a server-side vulnerability. - `python_transform` expressions are treated as **trusted code from a trusted client**. The sandbox exists to prevent accidental mistakes (e.g. a runaway loop), not to sandbox an adversarial party. Additionally, `python_transform` only ever receives data that ha-mcp fetched from the HA API and JSON-deserialized — no Python callables can reach the expression's `config` variable through any normal MCP or HA API call. See [python_sandbox.py](src/ha_mcp/utils/python_sandbox.py) for the explicit "not a security boundary" note. ### Custom dashboard cards are the user's own code To describe and check `custom:` cards, the `ha_mcp_tools` component runs the JavaScript files the user registered as dashboard resources (`/hacsfiles/...`, `/local/...`) in a QuickJS sandbox inside Home Assistant. ha-mcp installs no card; it only runs cards the user chose to install, the same files their browsers already run with a logged-in Home Assistant session. Deciding which cards are safe to install is the user's responsibility. The sandbox exposes no network, filesystem or process APIs, and caps each card's memory, file size and run time to contain a broken card, not an adversarial one. The DOM library it runs on (linkedom) is fetched from the npm registry at a pinned version and checked against its integrity hash before use. Inspection starts on the first relevant request, never at HA startup. QuickJS is an optional, exact-version dependency installed through HA's requirements manager under Core's constraints. An existing incompatible provider or dependency conflict disables this advice without replacing the provider or preventing the integration from loading. The shared `quickjs` module name means `quickjs` and `quickjs-ng` must not be installed over each other. ### Local network is the trusted zone for standard mode The HTTP entrypoint (`ha-mcp-web`) authenticates by URL-path secrecy and is designed for loopback HTTP or LAN HTTP with a high-entropy `MCP_SECRET_PATH`. Any peer that can reach the configured path is treated as trusted — securing the local network is outside ha-mcp's scope. On app (add-on) installs, ha-mcp additionally talks to the Supervisor REST API at `http://supervisor` with the Supervisor-issued token. That transport is the platform's contract, not a choice this project can harden: Supervisor serves its API over plain HTTP only (`web.TCPSite(..., port=80)` in `supervisor/api/__init__.py` — no TLS endpoint, no IPC socket), on the internal `hassio` docker network that is not reachable from the LAN. The isolation of that internal network is the boundary protecting the token, and it is enforced by the Home Assistant OS platform, not by ha-mcp. For internet-facing deployments use the OAuth entrypoint (`ha-mcp-oauth`) or, for gating access behind an external identity provider instead of per-user HA tokens, the OIDC entrypoint (`ha-mcp-oidc`; see [docs/oidc.md](docs/oidc.md)), behind a TLS-terminating reverse proxy (see [OAuth Mode](#oauth-mode--beta-warning) below). Deployment guidance: [development reference → Docker](docs/agents/development.md#docker). By default the HTTP entrypoints bind to `0.0.0.0` so they are reachable from other machines on the LAN. To restrict to the local machine, set `MCP_HOST=127.0.0.1` (or use `-p 127.0.0.1:8086:8086` at the Docker layer). ### Host/Origin validation (DNS-rebinding guard) is off by default fastmcp ships a Host/Origin guard — a DNS-rebinding defense that only accepts loopback `Host` headers and same-origin/loopback `Origin`s. ha-mcp defaults it off (`FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=false`) across its Streamable-HTTP entry points (`ha-mcp-web`, `ha-mcp-oauth`, `ha-mcp-oidc`, the app (add-on), and the in-process component server). The supported deployment model — reverse proxies, tunnels (Cloudflare, Nabu Casa), and direct LAN access — presents `Host` headers ha-mcp cannot enumerate, and the guard would otherwise reject them with `421`/`403` (including the plain browser landing page, a no-`Origin` navigation that still trips the `Host` check). This does not change the boundary defined above. URL-path secrecy (standard mode), the OAuth / Home Assistant session gates (OAuth and in-process modes), and the external identity provider gate (OIDC mode) remain the authentication boundary, and the local network is already the trusted zone — so the DNS-rebinding class this guard addresses is out of scope regardless. A DNS-rebinding attacker's browser still cannot reach the secret MCP path — only the public OAuth/OIDC discovery documents at fixed well-known paths (in standard/OAuth HTTP modes, the landing page shares the secret path) — and the loopback settings sidecar enforces its own Host/Origin allow-list independent of this setting. Operators who front ha-mcp differently can re-enable the guard by setting `FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=true` and pinning `FASTMCP_HTTP_ALLOWED_HOSTS` / `FASTMCP_HTTP_ALLOWED_ORIGINS`. ### Standard mode is single-tenant The secret-URL model (`ha-mcp-web`) assumes a single operator. All MCP clients that share the same `MCP_SECRET_PATH` get identical access — there is no per-client authorization or isolation. Reports that assume "client A shouldn't be able to see client B's data" don't apply to standard mode; that isolation model only exists in OAuth mode (scoped per HA token). ### ha-mcp does not add or restrict Home Assistant permissions ha-mcp uses the long-lived access token the operator provides. That token's permissions in Home Assistant are what they are. If the configured token is an admin token, ha-mcp can perform admin-level operations. Reports stating "ha-mcp can do X" where X is permitted by the configured token are not vulnerabilities — they are the intended behavior. ha-mcp expects an administrator's token; non-admin tokens are not officially supported but still work, with limitations: admin-only operations fail. Home Assistant answers an admin-only REST request from a non-admin with a 401 that `http.ban` counts toward an IP ban, so ha-mcp refuses those locally and calls services over WebSocket, where a refusal is not counted. To limit what an agent can do, use ha-mcp's tool security policies or disable tools rather than a non-admin token. ### Entity visibility enforce mode is best-effort concealment The opt-in entity visibility filter's **enforce mode** (`"enforce": true`, see the [Entity visibility filter FAQ](docs/FAQ.md#enforce-mode)) makes a hidden set unreadable across ordinary tool reads: direct reads of a hidden entity are refused with a generic not-found before the tool runs, and scannable content reads (dashboards, templates, automations, traces, logs, files) that would surface a hidden entity_id are refused on contact. Those enforced paths fail closed when registry data cannot be loaded. One shape-bounded exception preserves an allowed entity's state: JSON `ha_get_state` results omit fields, mapping keys, list items, or related records that name a hidden entity and add a warning listing the omitted JSON paths. An entity's whole `attributes` mapping is omitted when any of its content names a hidden entity, because attribute values derive from the related entity (a person's coordinates come from its device trackers) even where they do not name it. The result is scanned again after filtering; non-JSON output, a shape that cannot carry the warning, or any surviving hidden reference is refused. Direct requests for the hidden related entity remain concealed before execution. The warning necessarily discloses that the allowed result contained at least one hidden-entity reference and which paths were omitted. It does not name the hidden entity (a hidden mapping key is reported as ``) or reveal the omitted values. One diagnostic path is deliberately outside that guarantee by default: `ha_report_issue` bypasses both scans while `restrict_report_issue` is false, even when visibility data is healthy, and its report may contain logs naming a hidden entity. This is the troubleshooting escape hatch when the filter itself fails; operators can opt it into enforcement. A config that cannot be loaded with no last-good copy follows that unrestricted default for this one tool while all other calls fail closed. This is a strong barrier against an agent *incidentally* surfacing a hidden entity, not a cryptographic guarantee: enforcement scans for the hidden entity_id, so a Jinja template or sandbox-adjacent computation that *derives* a hidden entity's state without naming its entity_id cannot be caught. On enforced scannable paths, a matching hidden entity's data is not returned. Existence concealment is best-effort: per-tool not-found shapes vary, so a prober comparing errors may infer that an id is hidden rather than absent. The boundary covers Home Assistant entity data, not physical-world imagery: a visible camera stays readable (`ha_get_camera_image`) and could incidentally show a display that renders a hidden entity's state — hide that camera when its view is sensitive. Enforce mode is not a defense against an adversarial prompt author deliberately trying to exfiltrate a hidden entity's state, nor a substitute for Home Assistant's permission model; restrict what the configured token can reach in HA for a hard boundary. ### OAuth Bearer token design In OAuth mode, access and refresh tokens are HMAC-signed, stateless Bearer tokens. The token payload contains the user's Home Assistant long-lived access token (LLAT). This is **by design**: - The LLAT is the authorization boundary. Revoking it in Home Assistant immediately invalidates all derived tokens — that is the intended revocation path. - Tokens are HMAC-signed (preventing forgery and tampering) but not encrypted. Encrypting the payload would not improve security: the LLAT must ultimately be sent to Home Assistant in cleartext over HTTPS to authenticate API calls. Anyone with access to the MCP server process can observe the LLAT regardless of token format. - A party that captures a token can decode it to recover the LLAT. This is equivalent to capturing any other Bearer token that grants the same access — including the standard OAuth `client_credentials` model used by many MCP clients, where a static `client_secret` stored at the AI provider grants full service access. The trust boundary is identical; the only difference is packaging. - Token revocation at the ha-mcp level is a no-op: there is no server-side token store. Revoke the LLAT in Home Assistant instead. The consent form explains this revocation path. Reports about token opacity (the LLAT being visible inside the token) will be closed as by-design. ### In-process server (`ha_mcp_tools` in-process server entry) The `ha_mcp_tools` component's **in-process MCP server** config entry can run the ha-mcp server in-process inside Home Assistant and expose it through a Home Assistant webhook (see [docs/in-process-server.md](docs/in-process-server.md)). A new entry starts with the webhook disabled and the server port bound to loopback; the setup form and the entry options let an administrator enable remote access with one of three authentication postures (**Authentication mode**), or LAN access to the port. Entries created before setup offered that choice keep the webhook on in `none` mode with LAN binding. - **Secret webhook URL (`none`).** The webhook id is a high-entropy random string and *is* the credential — the same secret-URL trust model as standard mode above, except the URL is designed to be reached remotely through Home Assistant's own remote access (Nabu Casa or a TLS-terminating reverse proxy). Any party that has the full webhook URL is a trusted principal; keep the URL secret. **The secret URL is the main and only form of security in this mode.** The mode also serves an OAuth compatibility surface (discovery documents, an anonymous RFC 7591 registration endpoint, and an auto-approve authorization server) purely so OAuth-insisting connector brokers can complete a flow: the tokens it issues are cosmetic — bearers are ignored, the webhook URL remains the only credential. Because the auto-approve endpoint is anonymous, it redirects only to a callback on an administrator-editable allowlist (#2427): exact matching, plus the RFC 8252 §7.3 any-port rule for a listed `http` loopback callback. Anything else gets a 400 page and no redirect, so the Home Assistant origin is not an open redirector. The list ships with the common clients' published callbacks. It is stored on the server entry and edited on its Configure screen or in the admin-only sidebar panel; dynamic client registration cannot add to it. An administrator can turn the list off with **Allow any OAuth callback URL**, which accepts any `https` or loopback `http` callback without userinfo or a fragment and so makes that endpoint an open redirector again; it is off by default. - **Home Assistant account (`ha_auth`).** Home Assistant Core is the OAuth authorization server: the entry serves the discovery documents and validates inbound Bearer tokens against Home Assistant's own auth, so access is gated by a Home Assistant login — and restricted to **administrator** users. The server acts with its own administrator token (the caller's bearer is never forwarded), so accepting any valid login would grant every household member admin-equivalent control; non-admin, inactive, and system-generated users are rejected. This is distinct from the beta OAuth mode below — no bespoke authorization server or self-issued token is involved, and revoking the user's Home Assistant token/session revokes access. The component-scoped authorize/token/revoke endpoints front Core's own `/auth/*` (a browser redirect, a server-side token forward, and an RFC 7009 revocation forward) so the URLs clients cache are the component's — Core remains the authorization authority and performs its own validation on every request. Revocation is fronted because the refresh token the client holds is a signed envelope naming the identity Core bound the grant to, and Core answers 200 for a token it does not recognise: posting the envelope to Core directly would report a revocation that never happened. The scoped endpoint is anonymous exactly as Core's own is (RFC 7009 authorizes the bearer of the token, not a client identity). It makes no outbound request for a token that is not one of its own envelopes; a prefixed one is forwarded even when its signature does not verify, which is what keeps revocation working after the signing key rotates (removing and re-adding the integration mints a new one). That grants a forger nothing: possession is the only authorization a revocation needs, and Core's revocation endpoint is anonymous and idempotent, so an unverified body could just as well have been posted to Core directly. The refresh path is the strict one — an envelope whose signature it cannot verify is answered locally and never forwarded. For URL-shaped client identities Core would reject (cross-origin Client ID Metadata Document clients), the component validates the CIMD document itself per the MCP 2026-07-28 requirements (https-only fetch with no redirects, 10 KiB cap, exact `client_id` round-trip, `redirect_uris` match, loopback/IP-literal hosts refused) and forwards a same-origin-shaped client_id. This grants nothing new: Core already accepts any self-asserted redirect-origin client_id, so a validated translation authorizes only what a client could claim directly; anything failing validation is forwarded unchanged. The anonymous registration endpoint mints stateless public-client ids (HMAC-signed, embedding the registered redirect URIs — no server-side registration store); access still requires completing Core's admin login. - **Legacy OAuth (`legacy`).** A self-hosted OAuth 2.1 authorization server the component serves on its scoped endpoints (`/api/ha_mcp_tools/oauth/authorize` + `/token`, which discovery advertises) and additionally at the Home Assistant root (`/authorize` + `/token`) as an alias for metadata-ignoring clients, for OAuth-only MCP clients that HA Core's native OAuth cannot serve (Google Gemini Spark's cross-origin Client-ID-Metadata-Document redirect, GitHub Copilot CLI's dynamic registration). The credential is a **static `client_id` + `client_secret`** the component generates (or the admin overrides), plus a signing key — all persisted in the config entry. Its security properties: - **The client secret is the boundary.** Anyone holding the `client_id` + `client_secret` can complete the flow and mint tokens; there is no per-user identity. Access is **admin-equivalent** — the same server administrator token backs it as the other modes. Keep the secret secret. - **Self-issued Bearer tokens**, HMAC-signed and stateless, carrying `{kind, iat, exp, jti, cid}` — **no** Home Assistant LLAT (unlike the beta OAuth mode in "OAuth Bearer token design" above; that section's LLAT-revocation model does **not** apply here). Access tokens live 1 hour, refresh tokens 30 days. - **Revocation is rotation + restart.** Regenerating the credential or changing the `client_id`/`client_secret` override rotates the signing key, which invalidates every outstanding token — but only once Home Assistant restarts, because the root `/authorize`/`/token` views cannot be rebound without a restart (a repair issue prompts for it). Until that restart the previous credential keeps working. The credentials are shown only on the entry's Configure screen, never in the startup log, so a still-valid old token cannot read the rotated credential through the server's log tools. - **The consent endpoint is unauthenticated** (no HA session) — it is a plain human-approval page. This is safe because the authorization code is inert without the `client_secret` at the token endpoint (the client is authenticated before any code is redeemed) and PKCE S256 binds the code to the caller. - **Redirect URIs are validated to a spec floor, not exact-matched:** any `https://` URL (or `http://` loopback per RFC 8252, for CLI clients on variable ports) with a valid host/port and no fragment is accepted. There is deliberately **no** per-client redirect allowlist — the mode exists precisely for clients whose redirect URIs cannot be pre-registered (Spark's is cross-origin; Copilot CLI's loopback port varies). A permissive redirect is not exploitable for token theft here (the code is inert without the secret), so this is an accepted deviation from RFC 9700's exact-match guidance, scoped to this single-tenant self-hosted AS. - **TLS is required in practice** — the endpoints ride Home Assistant's own HTTP, so expose them only over HA's HTTPS remote access (Nabu Casa or a TLS-terminating reverse proxy), never plaintext over the internet. - **Route ownership:** the component and the Webhook Proxy app both bind the root `/authorize`/`/token`; only one may own them per Home Assistant instance. When the app already owns them, the component's legacy mode still enables and serves on its scoped endpoints only, logging a warning — metadata-honoring clients are unaffected; metadata-ignoring clients that guess root paths reach the app instead. Discovery never publishes the webhook id, in any of the three modes. The entry serves protected-resource metadata only at `/.well-known/oauth-protected-resource/api/webhook/` and its `/readonly` variant. Both require the caller to already hold the id, and the webhook's 401 challenge points to its matching document rather than a fixed, guessable path. Switching from `ha_auth` or `legacy` back to the secret-URL posture therefore does not promote a published value into the sole credential. Component versions before 2.1.1 also served the document at a fixed path, where it handed the full webhook URL to any unauthenticated GET while `ha_auth` or `legacy` was on; an install that ran either mode reachable from the internet on such a version should use the entry's **Regenerate connect secrets now** option once. The connect notification deliberately carries no secrets: Home Assistant shows persistent notifications to every authenticated user, so the webhook URL (the credential in secret-URL mode) is surfaced only on the administrator-only Configure screen of the entry; the log and notifications never carry it. New entries start with the webhook off, and a local-only option removes it entirely. The server reaches Home Assistant with an administrator's long-lived access token (#2427): one the administrator pastes, or one the component issues at setup from the login of a local-only account the administrator created for the server. The component never creates an account. It revokes a token it issued when the token is switched or the entry removed, and leaves a pasted token alone. The login skips that account's multi-factor step, but only an administrator can submit it. Entries created by older releases keep the account and token those releases provisioned while both remain valid; a missing, revoked or non-administrator credential stops the server and files a repair that asks for a replacement, rather than minting a new administrator. The token is stored in the config entry and handed to the server in memory (never through the Home Assistant process environment). Disabling the config entry stops the server; removing it deletes only an account an older release created, never an account the user created, and revokes only a token the component issued, never a pasted one. As with standard mode, that token's Home Assistant permissions define what the server can do. The component also adds an admin-only **settings panel** to the Home Assistant sidebar that reverse-proxies the server's web settings UI over Home Assistant's own HTTP. Because a browser cannot attach a Bearer token to a panel view, access is gated by a short-lived, HttpOnly session cookie that an authenticated **administrator** obtains through Home Assistant, and every proxied request re-validates that the session still maps to an active admin. The loopback secret path is never exposed to the browser and no token or secret is placed in a URL; the proxy returns 503 whenever the server is not running. ### Webhook Proxy app (`ha_mcp_webhook_proxy`) In the app's `ha_auth` mode the Home Assistant login is the credential, not the webhook URL. The URL without a Bearer gets a 401, and that 401 points at the RFC 9728 protected-resource document served under the webhook's own path (`/.well-known/oauth-protected-resource/api/webhook/`). The dev flavor also serves its `/readonly` variant. These metadata paths require the caller to already hold the id. Discovery therefore never publishes the webhook id in any mode, which is what lets the default posture with OAuth disabled keep treating that URL as the sole credential. **`ha_auth` is an access gate, not per-user authorization.** It validates the inbound Bearer against Home Assistant core and accepts any token core still honors, without inspecting the backing account: administrators, non-administrators, and system-generated accounts are all accepted alike. (Home Assistant core itself rejects a deactivated user's tokens, so deactivation does revoke proxy access.) The in-process entry above, by contrast, accepts only active human administrators. The caller's Bearer is never forwarded upstream: the ha-mcp app services every request with its own Supervisor/`homeassistant_api` privileges regardless of which account authenticated. A caller holding a credential Home Assistant core honors is a trusted principal per "MCP clients are trusted principals" above, which draws the line at possession of a working credential — not at holding any particular Home Assistant role. Treat a Home Assistant account on this instance as admin-equivalent wherever the app runs `ha_auth`: every account that can sign in reaches the same tool surface. Because the gate never inspects the account's role, demoting a user out of the administrator group does not revoke their access through the proxy — deactivate the account, which Home Assistant honors immediately by dropping that user's refresh tokens. Turning the app's OAuth mode off revokes nobody, and loosens the boundary: the webhook id persists across the switch, so that posture falls back to treating as the sole credential a URL every `ha_auth` client already holds. Rotate the id (the app's DOCS.md, "Rotating the webhook URL") when moving to the URL-secret posture. A fixed-path protected-resource document also handed that URL to any anonymous GET the whole time `ha_auth` or `legacy` was on, on app versions before 3.0.3.dev2 (dev channel) and before the stable release that promotes it: an install that ran either mode reachable from the internet on such a version should treat its webhook id as public and rotate once, whichever posture it is moving to. From this version no anonymous URL publishes the id, and a rotation takes effect immediately — the previous id's discovery URL answers 404 as soon as the app restarts with the new id, and a Repair prompts for the Home Assistant restart that binds the new id's discovery URL. The in-process entry's admin restriction is a property of that entry, not a guarantee of the app. The app's `legacy` mode carries the same properties as the in-process `legacy` mode described above — the component's implementation was ported from this app's — including the static `client_id`/`client_secret` as the sole boundary, admin-equivalent access, self-issued HMAC bearers, and revocation by credential rotation plus a restart. Only one of the two may own the root `/authorize` and `/token` routes per Home Assistant instance. ## Scope **In scope** — please report these: - Authentication bypass in standard (LLAT), OAuth, or OIDC mode - OAuth mode: XSS, SSRF, open redirect, or credential exfiltration via the consent form or token endpoint (i.e. an unauthenticated party obtaining a token or extracting credentials without completing the consent flow) - Unintended information disclosure via API responses (e.g. secrets returned to a client that shouldn't have them) - Privilege escalation within the MCP tool surface - Dependency vulnerabilities with a credible exploit path **Out of scope** — these will not be actioned: - Vulnerabilities in Home Assistant itself → report to [home-assistant/core](https://github.com/home-assistant/core/security) - Vulnerabilities in Nabu Casa or other remote access infrastructure - Attacks requiring physical access to the HA host - "The LLM performed a destructive action using valid, authorized tools" — this is a configuration or usage issue, not a security vulnerability. Tool visibility controls (`ENABLED_TOOL_MODULES`, group toggles) exist for this purpose. - Prompt injection that only travels through read-only tool return values — the MCP client controls what the LLM sees and acts on; hardening that path is the client's responsibility (see [Threat Model](#threat-model) above). - `python_transform` issues: the sandbox is not a security boundary between trusted and untrusted code. Problems with `python_transform` behavior are bugs, not security vulnerabilities. - LAN-peer access to standard-mode HTTP endpoints: the local network is the trusted zone (see [Threat Model](#threat-model) above). - DNS rebinding against the HTTP entrypoints: fastmcp's Host/Origin guard is off by default; URL-path secrecy is the boundary and the local network is the trusted zone (see [Threat Model](#threat-model) above). - The web settings UI not being gated by the OAuth/OIDC token: fastmcp custom routes bypass the auth middleware by design, so a dedicated secret path gates it instead (see [OAuth Mode](#oauth-mode--beta-warning) below). Reports placing the settings routes at `/settings` in OAuth or OIDC mode describe pre-7.14.0 behavior. - OAuth token containing an encoded LLAT: this is the Bearer token design (see [Threat Model](#threat-model) above). - OAuth token revocation not preventing further HA API access: revoke the LLAT in Home Assistant instead. - Saved custom tools (code mode, off by default) visible to other clients of the same server process: they are shared, persistent scaffolding — not per-user data — and `run_saved` executes with the caller's own HA token, granting no access the caller lacks. Any client that can reach ha-mcp is a trusted principal (see [Threat Model](#threat-model) above). - Any Home Assistant account being accepted by the Webhook Proxy app's `ha_auth` mode — non-administrators and system-generated accounts alike: holding a credential Home Assistant core honors makes a caller a trusted principal (see [Threat Model](#threat-model) above), which is the bar that mode sets. `ha_auth` is an access gate, not per-user authorization; the in-process entry's admin restriction is a property of that entry, not a guarantee of the app. - Vulnerabilities that are only exploitable due to a misconfigured deployment (e.g., standard-mode instance exposed to the internet without TLS, or a network-reachable HTTP entrypoint using the default `MCP_SECRET_PATH`). ## OAuth Mode — Beta Warning The OAuth consent-flow mode (`ha-mcp-oauth` entrypoint) is **experimental** and carries a larger attack surface than the standard LLAT setup. - Not recommended for production without TLS and network access restrictions - Requires explicit opt-in (`ha-mcp-oauth`); the default entrypoint is unaffected - CVEs were published and fixed in v7.x (XSS: GHSA-pf93-j98v-25pv; SSRF: GHSA-fmfg-9g7c-3vq7). Upgrade to the latest release before deploying. - The web settings UI is **not** gated by the OAuth token. It is served as fastmcp custom routes, which bypass `RequireAuthMiddleware`, so `mcp.auth` covers the MCP protocol endpoints only. Since 7.14.0 the UI is mounted under its own dedicated secret path — auto-generated as `/private_` unless `MCP_SETTINGS_SECRET_PATH` is set, never advertised to MCP clients, and printed only in the startup log (GHSA-mx64-982r-65vg). That path is a credential: the surface behind it edits feature flags (including `read_only_mode`, `redact_secrets`, and the filesystem tools), tool configuration, the tool-security policy and entity visibility, and can restore or delete backups. Set `HA_MCP_DISABLE_SETTINGS_UI` to not serve the UI at all. Standard mode instead mounts the UI under the MCP secret path, which already gates the tool surface. The app mounts it twice: under that secret path for direct access, and at the bare root for Home Assistant ingress, where the routes admit only the Supervisor peer (`172.30.32.2`) and 403 every other caller. If you choose to run OAuth mode, restrict the consent endpoint to trusted networks and place it behind a TLS-terminating reverse proxy. ### OIDC Mode The OIDC entrypoint (`ha-mcp-oidc`) is also **new**. It proxies authentication to your external identity provider via FastMCP's `OAuthProxy` — unlike OAuth mode's self-contained authorization server — and, like OAuth mode, exposes dynamic client registration (DCR) to any client that can reach the discovery endpoints. The same TLS/reverse-proxy recommendations apply, and `OIDC_ALLOWED_CLIENT_REDIRECT_URIS` should be set for internet-facing deployments (see [docs/oidc.md](docs/oidc.md)). The settings-UI caveat above applies identically: `MCP_SETTINGS_SECRET_PATH`, not the OIDC token, is what gates it. Three further properties of the mode: - **OIDC is an access gate, not per-user authorization.** Every authenticated user shares the one server-side `HOMEASSISTANT_TOKEN`, so all requests act as the same Home Assistant identity. As in standard mode, there is no per-user isolation — reports assuming user A cannot reach user B's data do not apply. Per-user Home Assistant credentials exist only in OAuth mode. - **The token audience is not checked by default.** With `OIDC_AUDIENCE` unset and `OIDC_VERIFY_ID_TOKEN` off, FastMCP's JWT verifier checks issuer, signature, and expiry but not `aud`. That is fine on an IdP dedicated to ha-mcp, and weaker on a shared one, where a token another client obtained from the same issuer would also pass. Set `OIDC_AUDIENCE` on a shared IdP. - **Sessions persist across restarts, and revocation is rotation.** When `OIDC_JWT_SIGNING_KEY` is unset, the session signing key is derived deterministically from `OIDC_CLIENT_SECRET`, so a restart does not log users out. To invalidate every outstanding session, rotate `OIDC_JWT_SIGNING_KEY` if it is set, and `OIDC_CLIENT_SECRET` otherwise. ## Reporting a Vulnerability Use the private reporting page at: **https://github.com/homeassistant-ai/ha-mcp/security/advisories/new** We aim to acknowledge reports within 7 days and to provide an initial assessment shortly after. Remediation time depends on severity and complexity. We practice coordinated disclosure and will agree a timeline with you, typically within 90 days of the initial report. Severity is assessed using CVSS base scores where applicable. **Requirements for a valid report:** - Reports must be made in good faith - Demonstrate a real, reproducible issue with steps to reproduce - Accurately reflect severity and impact — overstated reports are deprioritized - Low-quality or AI-generated submissions without a working proof of concept will be closed without action