Skip to content

Commit dc8eaa1

Browse files
julienldclaude
andauthored
feat!: fix SSRF and XSS in OAuth consent form (breaking) (#748)
* feat!: remove user-supplied HA URL from OAuth flow (SSRF fix) BREAKING CHANGE: OAuth mode now requires HOMEASSISTANT_URL as a server-side environment variable. The consent form no longer accepts a Home Assistant URL — only the Long-Lived Access Token is per-user. This eliminates the SSRF vulnerability (GHSA-fmfg-9g7c-3vq7) where an attacker could submit arbitrary URLs via the consent form to perform internal network reconnaissance through error oracle responses. Changes: - Remove ha_url from OAuth tokens and consent form (Fix 1) - Add warning box about token sharing with MCP client (Fix 2) - Apply html.escape() to all user-controlled values in consent_form.py (Fix 3) - Remove _validate_ha_credentials (no longer needed — token validated on first API call) - Update OAuthProxyClient to use global HOMEASSISTANT_URL + per-user token - Update docs/OAUTH.md with new setup instructions - Update all OAuth unit tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add breaking change notice to OAuth guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix uvx command syntax in OAuth guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: show redirect_uri domain on consent form instead of client name/scopes - Display the domain extracted from redirect_uri as the authorizing party - Remove security-note footer (redundant with warning box) - Return error if redirect_uri is missing from pending authorization - Remove client_name and scopes params from create_consent_html (unused) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address ruff lint errors and Gemini review comments - Remove unused HomeAssistantOAuthProvider import (F401) - Remove quotes from type annotations (UP037) - Replace chr(10) with '\n' in f-string for readability - Narrow except Exception to specific types in _extract_domain Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(internal): quote HomeAssistantClient return annotation for TYPE_CHECKING guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b703842 commit dc8eaa1

5 files changed

Lines changed: 293 additions & 421 deletions

File tree

docs/OAUTH.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
> **Status:** Beta - OAuth provides an alternative to the private URL method. It's fully functional but still being refined.
44
5-
OAuth authentication allows users to enter their Home Assistant credentials via a consent form instead of pre-configuring them on the server.
5+
> **Breaking change:** `HOMEASSISTANT_URL` is now a required environment variable in OAuth mode. The consent form no longer accepts a Home Assistant URL for security reasons.
6+
7+
OAuth authentication allows multiple users to authenticate with their own Home Assistant Long-Lived Access Token via a consent form.
68

79
## When to Use OAuth
810

@@ -36,32 +38,36 @@ For production, set up a [persistent Cloudflare Tunnel](https://developers.cloud
3638
```bash
3739
docker run -d --name ha-mcp-oauth \
3840
-p 8086:8086 \
41+
-e HOMEASSISTANT_URL=http://homeassistant.local:8123 \
3942
-e MCP_BASE_URL=https://your-tunnel.trycloudflare.com \
4043
ghcr.io/homeassistant-ai/ha-mcp:latest \
4144
ha-mcp-oauth
4245
```
4346

4447
**uvx:**
4548
```bash
49+
export HOMEASSISTANT_URL=http://homeassistant.local:8123
4650
export MCP_BASE_URL=https://your-tunnel.trycloudflare.com
47-
uvx ha-mcp@latest ha-mcp-oauth
51+
uvx --from=ha-mcp@latest ha-mcp-oauth
4852
```
4953

5054
### 3. Environment Variables
5155

5256
| Variable | Description | Default |
5357
|----------|-------------|---------|
58+
| `HOMEASSISTANT_URL` | **Required.** URL of the Home Assistant instance | None |
5459
| `MCP_BASE_URL` | **Required.** Public URL where this server is accessible | None |
5560
| `MCP_PORT` | Server port | `8086` |
5661
| `MCP_SECRET_PATH` | MCP endpoint path | `/mcp` |
5762

63+
> **Note:** `HOMEASSISTANT_TOKEN` is NOT required in OAuth mode. Each user provides their own Long-Lived Access Token via the consent form.
64+
5865
### 4. Connect in Claude.ai
5966

6067
1. Go to **Settings****Connectors****Add custom connector**
6168
2. Enter URL: `https://your-tunnel.com/mcp`
6269
3. Click **Add**
6370
4. In the consent form that opens:
64-
- Enter your Home Assistant URL (e.g., `http://homeassistant.local:8123`)
6571
- Enter your Long-Lived Access Token ([how to generate](https://www.home-assistant.io/docs/authentication/#your-account-profile))
6672
5. Click **Authorize**
6773

@@ -80,18 +86,14 @@ Make sure you're using the correct URL in Claude.ai:
8086

8187
The `/mcp` path is required - this is where the MCP server endpoints are mounted.
8288

83-
### "Invalid credentials" on consent form
84-
85-
Check your Home Assistant URL format:
86-
- Include protocol: `http://` or `https://`
87-
- Include port if not default: `:8123`
88-
- No trailing slash
89-
- Example: `http://homeassistant.local:8123`
89+
### "Invalid credentials" after authorizing
9090

9191
Verify your Long-Lived Access Token:
92-
- Generate fresh token in HA: Profile → Security → Long-lived access tokens
92+
- Generate a fresh token in HA: Profile → Security → Long-lived access tokens
9393
- Copy the complete token
9494

95+
Check that `HOMEASSISTANT_URL` is correct and accessible from the server running ha-mcp.
96+
9597
### Do tokens persist across server restarts?
9698

9799
**Yes!** Access tokens are stateless and self-contained - they work across server restarts and multi-instance deployments without any configuration.

src/ha_mcp/__main__.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
if TYPE_CHECKING:
2020
from fastmcp import FastMCP
2121

22-
from ha_mcp.auth.provider import HomeAssistantOAuthProvider
2322
from ha_mcp.client.rest_client import HomeAssistantClient
2423
from ha_mcp.config import Settings
2524
from ha_mcp.server import HomeAssistantSmartMCPServer
@@ -32,10 +31,13 @@ class OAuthProxyClient:
3231
3332
This class is necessary because tools capture a reference to the client at registration time.
3433
The proxy allows us to inject different credentials per-request based on OAuth token claims.
34+
35+
The Home Assistant URL is fixed server-side (HOMEASSISTANT_URL env var).
36+
Only the access token varies per-user (from OAuth consent form).
3537
"""
3638

37-
def __init__(self, auth_provider: "HomeAssistantOAuthProvider") -> None:
38-
self._auth_provider = auth_provider
39+
def __init__(self, ha_url: str) -> None:
40+
self._ha_url = ha_url.rstrip("/")
3941
self._oauth_clients: dict[str, HomeAssistantClient] = {}
4042
self._lock = threading.Lock()
4143

@@ -52,26 +54,25 @@ def _get_oauth_client(self) -> "HomeAssistantClient":
5254
logger.warning("No access token in context")
5355
raise RuntimeError("No OAuth token in request context")
5456

55-
# Extract HA credentials from token claims
57+
# Extract HA token from claims (URL is server-side config)
5658
claims = token.claims
5759

58-
if not claims or "ha_url" not in claims or "ha_token" not in claims:
60+
if not claims or "ha_token" not in claims:
5961
logger.error(f"OAuth token missing HA credentials. Keys present: {list(claims.keys()) if claims else []}")
6062
raise RuntimeError("No Home Assistant credentials in OAuth token claims")
6163

62-
ha_url = claims["ha_url"]
6364
ha_token = claims["ha_token"]
6465

65-
# Hash credentials for cache key to avoid raw tokens appearing in dict keys
66-
client_key = hashlib.sha256(f"{ha_url}:{ha_token}".encode()).hexdigest()
66+
# Hash token for cache key to avoid raw tokens appearing in dict keys
67+
client_key = hashlib.sha256(ha_token.encode()).hexdigest()
6768

6869
with self._lock:
6970
if client_key not in self._oauth_clients:
7071
self._oauth_clients[client_key] = HomeAssistantClient(
71-
base_url=ha_url,
72+
base_url=self._ha_url,
7273
token=ha_token,
7374
)
74-
logger.info(f"Created OAuth client for {ha_url}")
75+
logger.info(f"Created OAuth client for {self._ha_url}")
7576

7677
return self._oauth_clients[client_key]
7778

@@ -610,18 +611,19 @@ def main_sse() -> None:
610611
def main_oauth() -> None:
611612
"""Run server with OAuth 2.1 authentication over HTTP.
612613
613-
This mode enables zero-config authentication for MCP clients like Claude.ai.
614-
Users authenticate via a consent form where they enter their Home Assistant
615-
URL and Long-Lived Access Token.
614+
This mode enables per-user authentication for MCP clients like Claude.ai.
615+
Users authenticate via a consent form where they provide their
616+
Long-Lived Access Token.
616617
617618
Environment:
619+
- HOMEASSISTANT_URL (required): URL of the Home Assistant instance
618620
- MCP_BASE_URL (required): Public URL where this server is accessible (e.g., https://your-tunnel.com)
619621
- MCP_PORT (optional, default: 8086)
620622
- MCP_SECRET_PATH (optional, default: "/mcp")
621623
- LOG_LEVEL (optional, default: INFO)
622624
623-
Note: HOMEASSISTANT_URL and HOMEASSISTANT_TOKEN are NOT required in this mode.
624-
They are collected via the OAuth consent form.
625+
Note: HOMEASSISTANT_TOKEN is NOT required in this mode.
626+
Per-user tokens are collected via the OAuth consent form.
625627
"""
626628
# Configure logging for OAuth mode
627629
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -633,21 +635,45 @@ def main_oauth() -> None:
633635

634636
port, path = _get_http_runtime(default_port=8086)
635637
base_url = os.getenv("MCP_BASE_URL")
638+
ha_url = os.getenv("HOMEASSISTANT_URL")
636639

640+
missing = []
637641
if not base_url:
638-
logger.error("MCP_BASE_URL environment variable is required for OAuth mode")
639-
logger.error(
640-
"Example: export MCP_BASE_URL=https://your-tunnel.trycloudflare.com"
642+
missing.append(" - MCP_BASE_URL (e.g., https://your-tunnel.trycloudflare.com)")
643+
if not ha_url:
644+
missing.append(" - HOMEASSISTANT_URL (e.g., http://homeassistant.local:8123)")
645+
646+
if missing:
647+
missing_vars = "\n".join(missing)
648+
print(
649+
f"""
650+
==============================================================================
651+
Home Assistant MCP Server - Configuration Error
652+
==============================================================================
653+
654+
Missing required environment variables for OAuth mode:
655+
{missing_vars}
656+
657+
For setup instructions, see:
658+
https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/OAUTH.md
659+
660+
==============================================================================
661+
""",
662+
file=sys.stderr,
641663
)
642664
sys.exit(1)
643665

644-
_run_entrypoint(_run_oauth_server(base_url, port, path), "OAuth server")
666+
# Type narrowing: ha_url and base_url are guaranteed non-None after the check above
667+
assert ha_url is not None
668+
assert base_url is not None
669+
_run_entrypoint(_run_oauth_server(ha_url, base_url, port, path), "OAuth server")
645670

646671

647-
async def _run_oauth_server(base_url: str, port: int, path: str) -> None:
672+
async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) -> None:
648673
"""Run the OAuth-authenticated MCP server.
649674
650675
Args:
676+
ha_url: Home Assistant instance URL (server-side config)
651677
base_url: Public URL where this server is accessible (required)
652678
port: Port to listen on
653679
path: MCP endpoint path
@@ -661,9 +687,9 @@ async def _run_oauth_server(base_url: str, port: int, path: str) -> None:
661687
service_documentation_url="https://github.com/homeassistant-ai/ha-mcp",
662688
)
663689

664-
# In OAuth mode, credentials come from the OAuth consent form per-request.
665-
# The proxy client extracts them from token claims on each tool invocation.
666-
proxy_client = OAuthProxyClient(auth_provider)
690+
# In OAuth mode, the HA URL is fixed server-side. Per-user tokens come
691+
# from the OAuth consent form and are extracted from token claims.
692+
proxy_client = OAuthProxyClient(ha_url)
667693

668694
global _server
669695
_server = HomeAssistantSmartMCPServer(

0 commit comments

Comments
 (0)