From a18b40a34c0bd129ca03ef632adbb6d6f52f896c Mon Sep 17 00:00:00 2001 From: Miroslav Dvorak Date: Mon, 5 Oct 2026 06:37:31 +0000 Subject: [PATCH 1/2] feat: accept any HTTPS origin with optional port as public URL public_base() was limited to *.ui.nabu.casa on port 443. Allow any DNS hostname with an optional port so the connector works behind a self-hosted HTTPS endpoint (e.g. https://example.org:8125). IP literals, userinfo, path, query and fragment are still rejected. Update error texts and tests. --- custom_components/grok_connector/security.py | 15 +++++++++++---- custom_components/grok_connector/strings.json | 8 ++++---- .../grok_connector/translations/cs.json | 8 ++++---- .../grok_connector/translations/de.json | 8 ++++---- .../grok_connector/translations/en.json | 8 ++++---- .../grok_connector/translations/pl.json | 8 ++++---- .../grok_connector/translations/sk.json | 8 ++++---- tests/test_security.py | 9 +++++++-- 8 files changed, 42 insertions(+), 30 deletions(-) diff --git a/custom_components/grok_connector/security.py b/custom_components/grok_connector/security.py index 645735d..c8c8573 100644 --- a/custom_components/grok_connector/security.py +++ b/custom_components/grok_connector/security.py @@ -27,13 +27,20 @@ def digest(value): def public_base(value): + # Any HTTPS origin with a DNS hostname and an optional port; IP literals are rejected. parsed = urlsplit(value) if (parsed.scheme != "https" or not parsed.hostname - or not re.fullmatch(r"[a-z0-9-]+\.ui\.nabu\.casa", parsed.hostname) - or parsed.port not in (None, 443) or parsed.username or parsed.password + or not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+", + parsed.hostname) + or len(parsed.hostname) > 253 or re.fullmatch(r"[0-9.]+", parsed.hostname) + or parsed.username or parsed.password or parsed.path not in ("", "/") or parsed.query or parsed.fragment): - raise ValueError("A Nabu Casa HTTPS origin is required") - return "https://" + parsed.hostname + raise ValueError("An HTTPS origin with a DNS hostname is required") + try: + port = parsed.port + except ValueError: + raise ValueError("Invalid port") from None + return "https://" + parsed.hostname + (f":{port}" if port not in (None, 443) else "") def allowed_callback(value): diff --git a/custom_components/grok_connector/strings.json b/custom_components/grok_connector/strings.json index 24f6fd9..2fe2cac 100644 --- a/custom_components/grok_connector/strings.json +++ b/custom_components/grok_connector/strings.json @@ -4,9 +4,9 @@ "step": { "user": { "title": "Connect Grok", - "description": "Use the HTTPS address of Nabu Casa remote access. This connector has independent permissions and credentials.", + "description": "Use the public HTTPS address of your Home Assistant (Nabu Casa remote access or your own domain, optionally with a port). This connector has independent permissions and credentials.", "data": { - "public_url": "Nabu Casa HTTPS address", + "public_url": "Public HTTPS address", "language": "Language of MCP tools" } }, @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin ending in .ui.nabu.casa, without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin ending in .ui.nabu.casa, without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, diff --git a/custom_components/grok_connector/translations/cs.json b/custom_components/grok_connector/translations/cs.json index ddbfb5f..d896aab 100644 --- a/custom_components/grok_connector/translations/cs.json +++ b/custom_components/grok_connector/translations/cs.json @@ -4,9 +4,9 @@ "step": { "user": { "title": "Připojit Groka", - "description": "Použijte HTTPS adresu vzdáleného přístupu Nabu Casa. Tento konektor má vlastní oprávnění a přihlašovací údaje.", + "description": "Použijte veřejnou HTTPS adresu svého Home Assistantu (vzdálený přístup Nabu Casa nebo vlastní doména, případně s portem). Tento konektor má vlastní oprávnění a přihlašovací údaje.", "data": { - "public_url": "HTTPS adresa Nabu Casa", + "public_url": "Veřejná HTTPS adresa", "language": "Jazyk nástrojů MCP" } }, @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Zadejte samotnou HTTPS adresu končící .ui.nabu.casa, bez cesty a parametrů.", + "invalid_url": "Zadejte HTTPS adresu s názvem hostitele a volitelným portem (např. https://example.org:8125), bez cesty a parametrů.", "invalid_selection": "Vyberte nejvýše 64 entit.", "invalid_action": "Zadejte název, volitelný popis a platnou neprázdnou sekvenci akcí Home Assistantu." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Zadejte samotnou HTTPS adresu končící .ui.nabu.casa, bez cesty a parametrů.", + "invalid_url": "Zadejte HTTPS adresu s názvem hostitele a volitelným portem (např. https://example.org:8125), bez cesty a parametrů.", "invalid_selection": "Vyberte nejvýše 64 entit.", "invalid_action": "Zadejte název, volitelný popis a platnou neprázdnou sekvenci akcí Home Assistantu." }, diff --git a/custom_components/grok_connector/translations/de.json b/custom_components/grok_connector/translations/de.json index 3e53295..a49ae8a 100644 --- a/custom_components/grok_connector/translations/de.json +++ b/custom_components/grok_connector/translations/de.json @@ -4,9 +4,9 @@ "step": { "user": { "title": "Grok verbinden", - "description": "Verwenden Sie die HTTPS-Adresse des Nabu-Casa-Fernzugriffs. Dieser Connector hat eigene Berechtigungen und Zugangsdaten.", + "description": "Verwenden Sie die öffentliche HTTPS-Adresse Ihres Home Assistant (Nabu-Casa-Fernzugriff oder eigene Domain, optional mit Port). Dieser Connector hat eigene Berechtigungen und Zugangsdaten.", "data": { - "public_url": "HTTPS-Adresse von Nabu Casa", + "public_url": "Öffentliche HTTPS-Adresse", "language": "Sprache der MCP-Werkzeuge" } }, @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit der Endung .ui.nabu.casa ein, ohne Pfad oder Parameter.", + "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit Hostname und optionalem Port ein (z. B. https://example.org:8125), ohne Pfad oder Parameter.", "invalid_selection": "Wählen Sie höchstens 64 Entitäten aus.", "invalid_action": "Geben Sie einen Namen, eine optionale Beschreibung und eine gültige, nicht leere Home-Assistant-Aktionssequenz ein." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit der Endung .ui.nabu.casa ein, ohne Pfad oder Parameter.", + "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit Hostname und optionalem Port ein (z. B. https://example.org:8125), ohne Pfad oder Parameter.", "invalid_selection": "Wählen Sie höchstens 64 Entitäten aus.", "invalid_action": "Geben Sie einen Namen, eine optionale Beschreibung und eine gültige, nicht leere Home-Assistant-Aktionssequenz ein." }, diff --git a/custom_components/grok_connector/translations/en.json b/custom_components/grok_connector/translations/en.json index 24f6fd9..2fe2cac 100644 --- a/custom_components/grok_connector/translations/en.json +++ b/custom_components/grok_connector/translations/en.json @@ -4,9 +4,9 @@ "step": { "user": { "title": "Connect Grok", - "description": "Use the HTTPS address of Nabu Casa remote access. This connector has independent permissions and credentials.", + "description": "Use the public HTTPS address of your Home Assistant (Nabu Casa remote access or your own domain, optionally with a port). This connector has independent permissions and credentials.", "data": { - "public_url": "Nabu Casa HTTPS address", + "public_url": "Public HTTPS address", "language": "Language of MCP tools" } }, @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin ending in .ui.nabu.casa, without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin ending in .ui.nabu.casa, without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, diff --git a/custom_components/grok_connector/translations/pl.json b/custom_components/grok_connector/translations/pl.json index bdd6774..22d7ebb 100644 --- a/custom_components/grok_connector/translations/pl.json +++ b/custom_components/grok_connector/translations/pl.json @@ -4,9 +4,9 @@ "step": { "user": { "title": "Połącz Groka", - "description": "Użyj adresu HTTPS zdalnego dostępu Nabu Casa. Ten konektor ma własne uprawnienia i dane uwierzytelniające.", + "description": "Użyj publicznego adresu HTTPS swojego Home Assistanta (zdalny dostęp Nabu Casa lub własna domena, opcjonalnie z portem). Ten konektor ma własne uprawnienia i dane uwierzytelniające.", "data": { - "public_url": "Adres HTTPS Nabu Casa", + "public_url": "Publiczny adres HTTPS", "language": "Język narzędzi MCP" } }, @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Podaj wyłącznie adres HTTPS kończący się na .ui.nabu.casa, bez ścieżki i parametrów.", + "invalid_url": "Podaj adres HTTPS z nazwą hosta i opcjonalnym portem (np. https://example.org:8125), bez ścieżki i parametrów.", "invalid_selection": "Wybierz najwyżej 64 encje.", "invalid_action": "Podaj nazwę, opcjonalny opis i prawidłową niepustą sekwencję akcji Home Assistant." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Podaj wyłącznie adres HTTPS kończący się na .ui.nabu.casa, bez ścieżki i parametrów.", + "invalid_url": "Podaj adres HTTPS z nazwą hosta i opcjonalnym portem (np. https://example.org:8125), bez ścieżki i parametrów.", "invalid_selection": "Wybierz najwyżej 64 encje.", "invalid_action": "Podaj nazwę, opcjonalny opis i prawidłową niepustą sekwencję akcji Home Assistant." }, diff --git a/custom_components/grok_connector/translations/sk.json b/custom_components/grok_connector/translations/sk.json index 1247818..323c08c 100644 --- a/custom_components/grok_connector/translations/sk.json +++ b/custom_components/grok_connector/translations/sk.json @@ -4,9 +4,9 @@ "step": { "user": { "title": "Pripojiť Groka", - "description": "Použite HTTPS adresu vzdialeného prístupu Nabu Casa. Tento konektor má vlastné oprávnenia a prihlasovacie údaje.", + "description": "Použite verejnú HTTPS adresu svojho Home Assistanta (vzdialený prístup Nabu Casa alebo vlastná doména, prípadne s portom). Tento konektor má vlastné oprávnenia a prihlasovacie údaje.", "data": { - "public_url": "HTTPS adresa Nabu Casa", + "public_url": "Verejná HTTPS adresa", "language": "Jazyk nástrojov MCP" } }, @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Zadajte samotnú HTTPS adresu končiacu na .ui.nabu.casa, bez cesty a parametrov.", + "invalid_url": "Zadajte HTTPS adresu s názvom hostiteľa a voliteľným portom (napr. https://example.org:8125), bez cesty a parametrov.", "invalid_selection": "Vyberte najviac 64 entít.", "invalid_action": "Zadajte názov, voliteľný popis a platnú neprázdnu sekvenciu akcií Home Assistantu." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Zadajte samotnú HTTPS adresu končiacu na .ui.nabu.casa, bez cesty a parametrov.", + "invalid_url": "Zadajte HTTPS adresu s názvom hostiteľa a voliteľným portom (napr. https://example.org:8125), bez cesty a parametrov.", "invalid_selection": "Vyberte najviac 64 entít.", "invalid_action": "Zadajte názov, voliteľný popis a platnú neprázdnu sekvenciu akcií Home Assistantu." }, diff --git a/tests/test_security.py b/tests/test_security.py index 2f95d5e..5cd7d59 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -47,11 +47,16 @@ def exchange(self): "code_verifier": self.verifier} return self.auth.token(token_params), client, token_params - def test_only_nabu_https_origin(self): + def test_https_origin_with_optional_port(self): for value in ("http://test-house.ui.nabu.casa", BASE + "/path", BASE + "?token=abc", - "https://evil.example", "https://user@test-house.ui.nabu.casa", BASE + ":8443"): + "https://localhost", "https://192.168.1.1", "https://user@test-house.ui.nabu.casa", + BASE + ":99999", BASE + ":abc", "https://-bad.example.org"): with self.subTest(value=value), self.assertRaises(ValueError): security.public_base(value) + for value, expected in ((BASE, BASE), (BASE + ":443", BASE), (BASE + "/", BASE), + ("https://rpi4.mirecek.org:8125", "https://rpi4.mirecek.org:8125")): + with self.subTest(value=value): + self.assertEqual(security.public_base(value), expected) def test_redirect_allowlist_and_exact_match(self): for uri in ("https://grok.com.evil.example/cb", "https://evil-grok.com/cb", From a714ebca65b10b07621c51274383755c66605439 Mon Sep 17 00:00:00 2001 From: Jan Pruha Date: Mon, 5 Oct 2026 12:54:15 +0200 Subject: [PATCH 2/2] Harden custom HTTPS origins and prepare release 2026.10.5.1 --- CHANGELOG.md | 10 +++ README.md | 14 +++- custom_components/grok_connector/const.py | 2 +- .../grok_connector/manifest.json | 2 +- custom_components/grok_connector/security.py | 23 ++++-- custom_components/grok_connector/strings.json | 4 +- .../grok_connector/translations/cs.json | 4 +- .../grok_connector/translations/de.json | 4 +- .../grok_connector/translations/en.json | 4 +- .../grok_connector/translations/pl.json | 4 +- .../grok_connector/translations/sk.json | 4 +- docs/ARCHITECTURE.md | 2 +- docs/README.cs.md | 6 +- docs/README.de.md | 6 +- docs/README.pl.md | 6 +- docs/README.sk.md | 6 +- docs/SECURITY-ASSESSMENT-2026-10-05.md | 34 +++++++++ docs/VALIDATION.md | 7 ++ tests/test_http.py | 75 +++++++++++++++++++ tests/test_security.py | 19 +++++ 20 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 docs/SECURITY-ASSESSMENT-2026-10-05.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b88e55d..b21c6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2026.10.5.1 — 2026-10-05 + +- Support public HTTPS addresses beyond Nabu Casa: your own domain or reverse proxy, including nonstandard ports. +- Preserve independent OAuth permissions, exact Origin/resource/port binding and existing Nabu Casa grants. +- Reject ambiguous URLs, embedded control characters, credentials, invalid ports and browser IPv4 representations. +- Document valid TLS, proxy setup and the security assessment; update all five language guides. +- Update GitHub Actions checkout and Python setup to v7. + +Configure an origin such as `https://ha.example.org:8125`; the MCP URL is that origin followed by `/api/grok_connector/mcp`. The public endpoint needs a valid TLS certificate and must be reachable by Grok. Changing the configured origin requires fresh pairing. An update using the same origin preserves configuration and OAuth grants. Custom proxy/Grok live acceptance remains deployment-specific. + ## 2026.10.4.1 — 2026-10-04 First stable release, based on the accepted 0.1.0b7 implementation. diff --git a/README.md b/README.md index d47f3b4..0e97d10 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Select any entity for reading. Expose commands as named action sequences using H ## Requirements and current status -- Home Assistant **2026.9.4 or newer**, HACS and enabled Nabu Casa remote access. +- Home Assistant **2026.9.4 or newer**, HACS and public HTTPS access to HA. Use Nabu Casa remote access or your own DNS hostname with a valid TLS certificate; a nonstandard HTTPS port is supported. - A Grok client/account that offers custom remote MCP connectors with OAuth. Availability in Grok's Tesla interface is a separate client capability; this integration cannot enable it. - The beta series passed automated checks, GitHub CI, live HACS installation, native HA configuration/action editing and OAuth/MCP acceptance over Nabu Casa. Grok web in Chrome successfully paired and read selected states; the user confirmed light-on and light-off commands after the b7 fix. The first stable release packages that implementation with the requested date-based version. The Tesla client and other device sequences need their own acceptance testing. See [validation evidence](docs/VALIDATION.md). @@ -37,10 +37,10 @@ This is an independent integration, not the official Home Assistant MCP Server o Install the published release through HACS: 1. In HACS, open **Custom repositories** and add `https://github.com/Jenik5/GrokConnectorForHomeAssistant` as an **Integration**. -2. Download **2026.10.4.1** or a later stable release. Stable releases do not require beta versions to be enabled in HACS. +2. Download **2026.10.5.1** or a later stable release. Stable releases do not require beta versions to be enabled in HACS. 3. Restart Home Assistant to load the newly installed Python integration. 4. Open **Settings → Devices & services → Add integration → Grok Connector**. -5. Enter your Nabu Casa remote-access HTTPS origin, for example `https://your-instance.ui.nabu.casa`, and choose the MCP language. +5. Enter your public HTTPS origin, for example `https://your-instance.ui.nabu.casa` or `https://ha.example.org:8125`, and choose the MCP language. Enter only the origin, without credentials, a path, query or fragment. 6. Select entities whose states Grok may read. Any entity domain is allowed; the list can be empty for an actions-only connector. 7. Open **Configure → Actions** to see configured commands. Each row shows its name and description, with edit and remove icons. Use **Add action** below the list to add a command; submit the list to save your changes. Successful options saves close the dialog automatically; validation problems remain visible. @@ -108,6 +108,14 @@ The connector supports up to 64 reading entities and 64 named actions. Each sequ A separate HA user or HA long-lived access token is not needed: this server issues its own restricted credentials. Keep the pairing code private. Creating a new code invalidates the previous unused code. +### Using your own public address + +Version **2026.10.5.1** adds custom HTTPS origins beyond Nabu Casa; the older `2026.10.4.1` release remains Nabu Casa-only. The MCP URL is your configured origin followed by `/api/grok_connector/mcp`, for example `https://ha.example.org:8125/api/grok_connector/mcp`. + +Use a hostname you control, publicly reachable by the Grok client, with a certificate valid for that hostname. HTTPS is required on the public connection; the backend may use HTTP behind a trusted TLS-terminating reverse proxy. Preserve the connector's MCP, OAuth and `/.well-known/` paths, request bodies, query strings, cookies, `Authorization`, `Origin` and `MCP-Session-Id` headers. An interactive proxy login in front of these endpoints is not supported by this integration. Configure HA's `use_x_forwarded_for` and narrowly scoped `trusted_proxies` only as required by [HA's reverse proxy documentation](https://www.home-assistant.io/integrations/http/#reverse-proxies). + +The integration checks URL syntax, not DNS resolution, certificate validity or public reachability. IP literals, localhost/single-label names, credentials and paths are rejected. A terminal slash and explicit port 443 are normalized; other ports remain part of the OAuth issuer, resource and allowed Origin. Use trusted applications on the same hostname, since browser cookies are scoped to a hostname rather than a port. Changing the configured origin invalidates the resource binding of existing grants and requires new pairing. Existing Nabu Casa configurations retain the same canonical origin and credentials. See the [security assessment](docs/SECURITY-ASSESSMENT-2026-10-05.md). + Changing the reading list or any action, including its name/description or sequence, **revokes existing connector access**. Create a new pairing code and reconnect Grok. Changing only the language or an action icon preserves access. **Configure → Revoke Grok access** invalidates access/refresh tokens and pending authorization; approved OAuth client metadata remains available for reauthorization. ## Languages diff --git a/custom_components/grok_connector/const.py b/custom_components/grok_connector/const.py index 08862ee..1f0a519 100644 --- a/custom_components/grok_connector/const.py +++ b/custom_components/grok_connector/const.py @@ -1,6 +1,6 @@ """Independent paths and bounds of the community connector.""" DOMAIN = 'grok_connector' -VERSION = '2026.10.4.1' +VERSION = '2026.10.5.1' MCP_PATH = '/api/grok_connector/mcp' OAUTH_PATH = '/api/grok_connector/oauth' RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource' + MCP_PATH diff --git a/custom_components/grok_connector/manifest.json b/custom_components/grok_connector/manifest.json index 7bd2f93..36ac09d 100644 --- a/custom_components/grok_connector/manifest.json +++ b/custom_components/grok_connector/manifest.json @@ -18,5 +18,5 @@ "issue_tracker": "https://github.com/Jenik5/GrokConnectorForHomeAssistant/issues", "requirements": [], "single_config_entry": true, - "version": "2026.10.4.1" + "version": "2026.10.5.1" } diff --git a/custom_components/grok_connector/security.py b/custom_components/grok_connector/security.py index c8c8573..d907689 100644 --- a/custom_components/grok_connector/security.py +++ b/custom_components/grok_connector/security.py @@ -27,20 +27,31 @@ def digest(value): def public_base(value): - # Any HTTPS origin with a DNS hostname and an optional port; IP literals are rejected. + """Canonical HTTPS origin selected by the HA administrator, never a fetch target.""" + # urlsplit silently strips some control characters. Reject them before parsing. + if (not isinstance(value, str) or len(value) > 2048 + or any(ord(c) < 33 or ord(c) == 127 for c in value) + or "?" in value or "#" in value): + raise ValueError("An HTTPS origin with a DNS hostname is required") parsed = urlsplit(value) + host = parsed.hostname or "" + last_label = host.rsplit(".", 1)[-1] if (parsed.scheme != "https" or not parsed.hostname or not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+", - parsed.hostname) - or len(parsed.hostname) > 253 or re.fullmatch(r"[0-9.]+", parsed.hostname) - or parsed.username or parsed.password - or parsed.path not in ("", "/") or parsed.query or parsed.fragment): + host) + or len(host) > 253 + # Browsers interpret hosts ending in a number as IPv4, including hex/octal. + or re.fullmatch(r"[0-9]+|0x[0-9a-f]*", last_label) + or parsed.username is not None or parsed.password is not None + or parsed.netloc.endswith(":") or parsed.path not in ("", "/")): raise ValueError("An HTTPS origin with a DNS hostname is required") try: port = parsed.port except ValueError: raise ValueError("Invalid port") from None - return "https://" + parsed.hostname + (f":{port}" if port not in (None, 443) else "") + if port is not None and not 1 <= port <= 65535: + raise ValueError("Invalid port") + return "https://" + host + (f":{port}" if port not in (None, 443) else "") def allowed_callback(value): diff --git a/custom_components/grok_connector/strings.json b/custom_components/grok_connector/strings.json index 2fe2cac..fa61e4d 100644 --- a/custom_components/grok_connector/strings.json +++ b/custom_components/grok_connector/strings.json @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port, without credentials, a path, query or fragment.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port, without credentials, a path, query or fragment.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, diff --git a/custom_components/grok_connector/translations/cs.json b/custom_components/grok_connector/translations/cs.json index d896aab..9ac162c 100644 --- a/custom_components/grok_connector/translations/cs.json +++ b/custom_components/grok_connector/translations/cs.json @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Zadejte HTTPS adresu s názvem hostitele a volitelným portem (např. https://example.org:8125), bez cesty a parametrů.", + "invalid_url": "Zadejte samotnou HTTPS adresu s názvem hostitele a volitelným portem, bez přihlašovacích údajů, cesty, parametrů a fragmentu.", "invalid_selection": "Vyberte nejvýše 64 entit.", "invalid_action": "Zadejte název, volitelný popis a platnou neprázdnou sekvenci akcí Home Assistantu." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Zadejte HTTPS adresu s názvem hostitele a volitelným portem (např. https://example.org:8125), bez cesty a parametrů.", + "invalid_url": "Zadejte samotnou HTTPS adresu s názvem hostitele a volitelným portem, bez přihlašovacích údajů, cesty, parametrů a fragmentu.", "invalid_selection": "Vyberte nejvýše 64 entit.", "invalid_action": "Zadejte název, volitelný popis a platnou neprázdnou sekvenci akcí Home Assistantu." }, diff --git a/custom_components/grok_connector/translations/de.json b/custom_components/grok_connector/translations/de.json index a49ae8a..07dec6c 100644 --- a/custom_components/grok_connector/translations/de.json +++ b/custom_components/grok_connector/translations/de.json @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit Hostname und optionalem Port ein (z. B. https://example.org:8125), ohne Pfad oder Parameter.", + "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit Hostname und optionalem Port ein, ohne Zugangsdaten, Pfad, Parameter oder Fragment.", "invalid_selection": "Wählen Sie höchstens 64 Entitäten aus.", "invalid_action": "Geben Sie einen Namen, eine optionale Beschreibung und eine gültige, nicht leere Home-Assistant-Aktionssequenz ein." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit Hostname und optionalem Port ein (z. B. https://example.org:8125), ohne Pfad oder Parameter.", + "invalid_url": "Geben Sie nur eine HTTPS-Adresse mit Hostname und optionalem Port ein, ohne Zugangsdaten, Pfad, Parameter oder Fragment.", "invalid_selection": "Wählen Sie höchstens 64 Entitäten aus.", "invalid_action": "Geben Sie einen Namen, eine optionale Beschreibung und eine gültige, nicht leere Home-Assistant-Aktionssequenz ein." }, diff --git a/custom_components/grok_connector/translations/en.json b/custom_components/grok_connector/translations/en.json index 2fe2cac..fa61e4d 100644 --- a/custom_components/grok_connector/translations/en.json +++ b/custom_components/grok_connector/translations/en.json @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port, without credentials, a path, query or fragment.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Enter only an HTTPS origin with a hostname and optional port (for example https://example.org:8125), without a path or query.", + "invalid_url": "Enter only an HTTPS origin with a hostname and optional port, without credentials, a path, query or fragment.", "invalid_selection": "Choose at most 64 entities.", "invalid_action": "Enter a name, an optional description and a valid nonempty Home Assistant action sequence." }, diff --git a/custom_components/grok_connector/translations/pl.json b/custom_components/grok_connector/translations/pl.json index 22d7ebb..9d53593 100644 --- a/custom_components/grok_connector/translations/pl.json +++ b/custom_components/grok_connector/translations/pl.json @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Podaj adres HTTPS z nazwą hosta i opcjonalnym portem (np. https://example.org:8125), bez ścieżki i parametrów.", + "invalid_url": "Podaj wyłącznie adres HTTPS z nazwą hosta i opcjonalnym portem, bez danych logowania, ścieżki, parametrów i fragmentu.", "invalid_selection": "Wybierz najwyżej 64 encje.", "invalid_action": "Podaj nazwę, opcjonalny opis i prawidłową niepustą sekwencję akcji Home Assistant." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Podaj adres HTTPS z nazwą hosta i opcjonalnym portem (np. https://example.org:8125), bez ścieżki i parametrów.", + "invalid_url": "Podaj wyłącznie adres HTTPS z nazwą hosta i opcjonalnym portem, bez danych logowania, ścieżki, parametrów i fragmentu.", "invalid_selection": "Wybierz najwyżej 64 encje.", "invalid_action": "Podaj nazwę, opcjonalny opis i prawidłową niepustą sekwencję akcji Home Assistant." }, diff --git a/custom_components/grok_connector/translations/sk.json b/custom_components/grok_connector/translations/sk.json index 323c08c..75c9f51 100644 --- a/custom_components/grok_connector/translations/sk.json +++ b/custom_components/grok_connector/translations/sk.json @@ -18,7 +18,7 @@ } }, "error": { - "invalid_url": "Zadajte HTTPS adresu s názvom hostiteľa a voliteľným portom (napr. https://example.org:8125), bez cesty a parametrov.", + "invalid_url": "Zadajte samotnú HTTPS adresu s názvom hostiteľa a voliteľným portom, bez prihlasovacích údajov, cesty, parametrov a fragmentu.", "invalid_selection": "Vyberte najviac 64 entít.", "invalid_action": "Zadajte názov, voliteľný popis a platnú neprázdnu sekvenciu akcií Home Assistantu." }, @@ -66,7 +66,7 @@ } }, "error": { - "invalid_url": "Zadajte HTTPS adresu s názvom hostiteľa a voliteľným portom (napr. https://example.org:8125), bez cesty a parametrov.", + "invalid_url": "Zadajte samotnú HTTPS adresu s názvom hostiteľa a voliteľným portom, bez prihlasovacích údajov, cesty, parametrov a fragmentu.", "invalid_selection": "Vyberte najviac 64 entít.", "invalid_action": "Zadajte názov, voliteľný popis a platnú neprázdnu sekvenciu akcií Home Assistantu." }, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7628ce8..6762b88 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,7 +12,7 @@ The reading list is a data-disclosure boundary, not an action-target boundary. A ## OAuth boundary -`security.py` owns an independent authority; it never accepts or forwards HA credentials. Resource/issuer URLs are bound to the configured Nabu Casa HTTPS origin. Temporary registrations, authorization transactions and pairing secrets expire. Approved client metadata and hashed grants are stored with HA's supported Store API. +`security.py` owns an independent authority; it never accepts or forwards HA credentials. Resource/issuer URLs are bound to the administrator-configured public HTTPS origin, including any nonstandard port. Both Nabu Casa and custom DNS hostnames are accepted. URL syntax validation rejects ambiguous authorities and browser IPv4 representations; it performs no DNS lookup or outbound request. Public reachability and valid TLS are the operator's responsibility. Temporary registrations, authorization transactions and pairing secrets expire. Approved client metadata and hashed grants are stored with HA's supported Store API. See the [custom-origin security assessment](SECURITY-ASSESSMENT-2026-10-05.md). The browser form uses per-transaction Secure/HttpOnly/SameSite=Lax cookies, same-origin referrer metadata, HTML escaping and script-free CSP. `form-action` includes only the verified registered callback origin because Chrome applies this policy to the OAuth redirect as well. Authorization POST rejects a missing or mismatching transaction cookie and a foreign Origin. The server permits native/server clients that omit Origin; possession of the cookie and the single-use pairing secret is still required. diff --git a/docs/README.cs.md b/docs/README.cs.md index e0f0882..86faf93 100644 --- a/docs/README.cs.md +++ b/docs/README.cs.md @@ -6,9 +6,11 @@ První stabilní vydání má verzi `2026.10.4.1` a vychází z otestované bety ## Instalace přes HACS -Potřebuješ HA 2026.9.4+, HACS a vzdálený přístup Nabu Casa. V HACS přidej vlastní repozitář `https://github.com/Jenik5/GrokConnectorForHomeAssistant`, typ **Integrace**. Stáhni verzi a restartuj HA. Pak v **Nastavení → Zařízení a služby → Přidat integraci** vyber **Grok Connector**. +Potřebuješ HA 2026.9.4+, HACS a veřejný HTTPS přístup přes Nabu Casa nebo vlastní doménu s platným TLS certifikátem. V HACS přidej vlastní repozitář `https://github.com/Jenik5/GrokConnectorForHomeAssistant`, typ **Integrace**. Stáhni verzi a restartuj HA. Pak v **Nastavení → Zařízení a služby → Přidat integraci** vyber **Grok Connector**. -Zadej HTTPS adresu Nabu Casa bez cesty a vyber jazyk nástrojů. Označ entity ke čtení; jejich typ není omezen. V **Konfigurovat → Akce → Přidat akci** zadej název, popis pro Groka a sekvenci akcí. Samotné zařazení entity do seznamu ke čtení neumožňuje její ovládání. +Zadej veřejnou HTTPS adresu bez cesty, např. `https://tvoje-instance.ui.nabu.casa` nebo `https://ha.example.org:8125`, a vyber jazyk nástrojů. Podpora vlastních domén je dostupná od verze `2026.10.5.1`; starší vydání `2026.10.4.1` zůstává omezené na Nabu Casa. Veřejná adresa musí být dostupná Groku a mít platný certifikát. Port je součástí oprávnění; změna adresy vyžaduje nové párování. [Vlastní adresa a reverzní proxy](../README.md#using-your-own-public-address). + +Označ entity ke čtení; jejich typ není omezen. V **Konfigurovat → Akce → Přidat akci** zadej název, popis pro Groka a sekvenci akcí. Samotné zařazení entity do seznamu ke čtení neumožňuje její ovládání. ## Příklady akcí diff --git a/docs/README.de.md b/docs/README.de.md index c4722ef..cd89588 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -6,9 +6,11 @@ Die erste stabile Version `2026.10.4.1` basiert auf der getesteten Beta `0.1.0b7 ## Einrichtung über HACS -Voraussetzungen: HA 2026.9.4+, HACS und aktivierter Nabu-Casa-Fernzugriff. Füge in HACS `https://github.com/Jenik5/GrokConnectorForHomeAssistant` als benutzerdefiniertes Repository vom Typ **Integration** hinzu. Lade die Version herunter, starte HA neu und füge **Grok Connector** unter **Einstellungen → Geräte & Dienste** hinzu. +Voraussetzungen: HA 2026.9.4+, HACS und öffentlicher HTTPS-Zugriff über Nabu Casa oder eine eigene Domain mit gültigem TLS-Zertifikat. Füge in HACS `https://github.com/Jenik5/GrokConnectorForHomeAssistant` als benutzerdefiniertes Repository vom Typ **Integration** hinzu. Lade die Version herunter, starte HA neu und füge **Grok Connector** unter **Einstellungen → Geräte & Dienste** hinzu. -Gib die Nabu-Casa-HTTPS-Adresse ohne Pfad ein und wähle die Sprache der MCP-Werkzeuge. Wähle die lesbaren Entitäten. Unter **Konfigurieren → Aktionen** kannst du einen Namen, eine Beschreibung für Grok und eine Aktionsfolge mit dem HA-Editor anlegen. Leseberechtigung allein erlaubt keine Steuerung. +Gib die öffentliche HTTPS-Adresse ohne Pfad ein, z. B. `https://deine-instanz.ui.nabu.casa` oder `https://ha.example.org:8125`, und wähle die Sprache der MCP-Werkzeuge. Eigene Domains werden ab Version `2026.10.5.1` unterstützt; die ältere Veröffentlichung `2026.10.4.1` bleibt auf Nabu Casa beschränkt. Die Adresse muss für Grok erreichbar sein und ein gültiges Zertifikat haben. Der Port gehört zur Berechtigung; eine geänderte Adresse erfordert neue Kopplung. [Eigene Adresse und Reverse Proxy](../README.md#using-your-own-public-address). + +Wähle die lesbaren Entitäten. Unter **Konfigurieren → Aktionen** kannst du einen Namen, eine Beschreibung für Grok und eine Aktionsfolge mit dem HA-Editor anlegen. Leseberechtigung allein erlaubt keine Steuerung. ## Aktionen diff --git a/docs/README.pl.md b/docs/README.pl.md index 9618f2e..cd5dfed 100644 --- a/docs/README.pl.md +++ b/docs/README.pl.md @@ -6,9 +6,11 @@ Pierwsza stabilna wersja `2026.10.4.1` opiera się na przetestowanej becie `0.1. ## Konfiguracja przez HACS -Wymagane są HA 2026.9.4+, HACS i włączony dostęp zdalny Nabu Casa. Dodaj `https://github.com/Jenik5/GrokConnectorForHomeAssistant` w HACS jako własne repozytorium typu **Integracja**, pobierz wersję i uruchom HA ponownie. Dodaj **Grok Connector** w **Ustawienia → Urządzenia i usługi**. +Wymagane są HA 2026.9.4+, HACS i publiczny dostęp HTTPS przez Nabu Casa lub własną domenę z ważnym certyfikatem TLS. Dodaj `https://github.com/Jenik5/GrokConnectorForHomeAssistant` w HACS jako własne repozytorium typu **Integracja**, pobierz wersję i uruchom HA ponownie. Dodaj **Grok Connector** w **Ustawienia → Urządzenia i usługi**. -Podaj adres HTTPS Nabu Casa bez ścieżki, wybierz język narzędzi MCP i encje do odczytu. W **Konfiguruj → Akcje** dodaj nazwę, opis dla Groka i sekwencję w edytorze HA. Wybranie encji do odczytu nie pozwala samo w sobie na jej sterowanie. +Podaj publiczny adres HTTPS bez ścieżki, np. `https://twoja-instancja.ui.nabu.casa` lub `https://ha.example.org:8125`, i wybierz język narzędzi MCP. Własne domeny są obsługiwane od wersji `2026.10.5.1`; starsze wydanie `2026.10.4.1` pozostaje ograniczone do Nabu Casa. Adres musi być dostępny dla Groka i mieć ważny certyfikat. Port jest częścią uprawnień; zmiana adresu wymaga ponownego parowania. [Własny adres i reverse proxy](../README.md#using-your-own-public-address). + +Wybierz encje do odczytu. W **Konfiguruj → Akcje** dodaj nazwę, opis dla Groka i sekwencję w edytorze HA. Wybranie encji do odczytu nie pozwala samo w sobie na jej sterowanie. ## Przykłady akcji diff --git a/docs/README.sk.md b/docs/README.sk.md index b6c154b..5d36e28 100644 --- a/docs/README.sk.md +++ b/docs/README.sk.md @@ -6,9 +6,11 @@ Prvé stabilné vydanie má verziu `2026.10.4.1` a vychádza z otestovanej bety ## Nastavenie cez HACS -Potrebuješ HA 2026.9.4+, HACS a zapnutý vzdialený prístup Nabu Casa. V HACS pridaj `https://github.com/Jenik5/GrokConnectorForHomeAssistant` ako vlastný repozitár typu **Integrácia**, stiahni verziu a reštartuj HA. Pridaj **Grok Connector** v **Nastavenia → Zariadenia a služby**. +Potrebuješ HA 2026.9.4+, HACS a verejný HTTPS prístup cez Nabu Casa alebo vlastnú doménu s platným TLS certifikátom. V HACS pridaj `https://github.com/Jenik5/GrokConnectorForHomeAssistant` ako vlastný repozitár typu **Integrácia**, stiahni verziu a reštartuj HA. Pridaj **Grok Connector** v **Nastavenia → Zariadenia a služby**. -Zadaj HTTPS adresu Nabu Casa bez cesty, vyber jazyk nástrojov MCP a entity na čítanie. V **Konfigurovať → Akcie** pridaj názov, opis pre Groka a sekvenciu v editore HA. Zaradenie entity do zoznamu na čítanie samo osebe neumožňuje jej ovládanie. +Zadaj verejnú HTTPS adresu bez cesty, napr. `https://tvoja-instancia.ui.nabu.casa` alebo `https://ha.example.org:8125`, a vyber jazyk nástrojov MCP. Podpora vlastných domén je dostupná od verzie `2026.10.5.1`; staršie vydanie `2026.10.4.1` zostáva obmedzené na Nabu Casa. Adresa musí byť dostupná Groku a mať platný certifikát. Port je súčasťou oprávnenia; zmena adresy vyžaduje nové párovanie. [Vlastná adresa a reverzná proxy](../README.md#using-your-own-public-address). + +Vyber entity na čítanie. V **Konfigurovať → Akcie** pridaj názov, opis pre Groka a sekvenciu v editore HA. Zaradenie entity do zoznamu na čítanie samo osebe neumožňuje jej ovládanie. ## Príklady akcií diff --git a/docs/SECURITY-ASSESSMENT-2026-10-05.md b/docs/SECURITY-ASSESSMENT-2026-10-05.md new file mode 100644 index 0000000..d783d79 --- /dev/null +++ b/docs/SECURITY-ASSESSMENT-2026-10-05.md @@ -0,0 +1,34 @@ +# Custom HTTPS origin security assessment + +Date: 2026-10-05. Scope: PR [#3](https://github.com/Jenik5/GrokConnectorForHomeAssistant/pull/3), original head `a18b40a34c0bd129ca03ef632adbb6d6f52f896c`, and the maintainer's validation hardening in that PR. This is a source review and automated regression assessment, not an independent penetration test or TLS/proxy deployment audit. + +## Decision + +Accept custom HTTPS DNS origins, including nonstandard ports, with the validation fixes and regression coverage described below. No new authorization bypass was identified in the reviewed change. The independent OAuth authority, configured permissions and exact resource/Origin binding remain in place. A correctly configured public HTTPS proxy can preserve these application-level protections; the integration does not certify the security of an operator's external access arrangement. + +## Trust boundary + +The HA administrator selects the public origin through the existing admin configuration flow. This input is not supplied by unauthenticated OAuth or MCP callers. The authority uses it to construct its issuer, resource and discovery metadata; it does not resolve or fetch that URL. Accepting another DNS suffix therefore does not introduce an outbound HTTP request in HA. Discovery URLs may nevertheless be fetched by Grok: client-side SSRF and DNS-rebinding protections remain the client's responsibility, as described in [MCP security guidance](https://modelcontextprotocol.io/docs/2025-11-25/tutorials/security/security_best_practices#server-side-request-forgery-ssrf). + +Changing the public origin does not broaden OAuth callback registration: HTTPS callbacks remain restricted to `grok.com`, `x.ai` and their subdomains, with exact registered-URI matching. PKCE S256, single-use pairing/authorization codes, transaction cookies, token hashing, refresh rotation and revocation are retained. MCP sessions remain bound to the authenticated grant. HA credentials and long-lived access tokens remain unusable as connector credentials. + +Nonstandard ports are retained in issuer, resource, discovery endpoints and allowed Origin. Another port on the same hostname is a different authorization target. Port 443 and a terminal slash normalize to the existing origin, preserving existing Nabu Casa grants. Tokens saved against another origin fail authentication. Browser Origin validation remains exact, in accordance with the [MCP transport requirements](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning). + +## Findings and fixes + +The original PR passed the existing 77 Python tests but accepted ambiguous administrator input. These were validation gaps, not demonstrated remote authorization bypasses: + +- Control characters and leading whitespace could be silently removed by `urlsplit`; they are now rejected before parsing. [Python documents that URL parsing is not validation](https://docs.python.org/3/library/urllib.parse.html#url-parsing-security). +- Empty userinfo (`https://@ha.example.org`, `https://:@ha.example.org`), an empty port and port 0 were accepted. Userinfo is now checked by presence and ports must be 1–65535. +- Empty query/fragment delimiters were accepted; both are now rejected even without a value. +- `https://0x7f.0.0.1` passed the DNS expression although browser URL parsers treat it as IPv4 loopback. Numeric/hex terminal labels are now rejected, covering browser IPv4 forms under the [WHATWG host-parsing rules](https://url.spec.whatwg.org/#host-parsing). + +The new negative test reproduced 13 rejected-input subcases being incorrectly accepted before hardening. They all pass after the fix. Valid ordinary DNS, punycode names, custom ports, Nabu Casa URLs and canonical default-port URLs remain accepted. HTTP, IP literals, malformed labels, credentials and non-root paths remain rejected. Rejection of IP literals is a syntax policy; DNS names may still resolve to private addresses. + +## Verification and limits + +Automated tests exercise the shipped OAuth authority and HTTP handlers with HA/aiohttp adapters. New coverage checks custom-port discovery metadata, consent cookies and CSP, wrong-resource authorization/code exchange, wrong-port Origin rejection, successful PKCE exchange and refresh rotation, and rejection of old grants after a port change. Existing Nabu Casa grants survive default-port canonicalization. The suite also retains existing callback, replay, permission and session-isolation regressions. + +Local validation passed 80 Python tests, 5 JavaScript tests and package checks. GitHub CI additionally validates Python 3.13/3.14, hassfest and HACS before merge. Automated tests do not establish live Grok compatibility through a particular custom proxy; the contributor's reported successful pairing is not maintainer live-test evidence. + +The operator must provide a hostname they control, valid public TLS and reachability from Grok, preserve discovery/OAuth/MCP paths and headers, and configure only the actual trusted HA proxies. There is no insecure HTTP or certificate-validation bypass. Browser cookies are host-scoped, not port-scoped, so other applications on the same hostname must also be trusted. External interactive proxy authentication may prevent Grok from connecting. Existing release `2026.10.4.1` is unchanged; custom-origin support is included in release `2026.10.5.1`. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 61c7eea..be8447a 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -173,3 +173,10 @@ See [the acceptance procedure](RELEASING.md) for deliberate follow-up tests. the confirmed light-on/light-off retest and the separate Tesla acceptance limit. - Publication uses a stable GitHub release rather than a prerelease. A live HA installation of this stable tag is a separate acceptance step. + + +## Custom public HTTPS origins — 2026-10-05 / 2026.10.5.1 + +The custom-origin contribution and maintainer hardening passed 80 local Python tests, 5 JavaScript tests and package checks. New regressions cover ambiguous URLs, browser IPv4 representations, custom-port discovery metadata, exact Origin/resource boundaries, PKCE consent/exchange, refresh rotation and existing Nabu Casa grant compatibility. See the [security assessment](SECURITY-ASSESSMENT-2026-10-05.md). + +These checks use the shipped code with framework adapters. No live HA deployment, proxy/TLS audit, physical action or Tesla test was performed for this release preparation. The contributor reported successful custom-domain pairing; that is separate from maintainer-verified acceptance. diff --git a/tests/test_http.py b/tests/test_http.py index 98f3a3d..9d4b8fa 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -403,6 +403,81 @@ async def test_form_permits_only_registered_callback_origin_and_keeps_other_prot self.assertTrue(next(iter(response.cookies.values()))["httponly"]) self.assertEqual(next(iter(response.cookies.values()))["samesite"], "Lax") + async def test_custom_https_port_preserves_oauth_origin_and_resource_boundary(self): + base = "https://ha.example.org:8125" + self.authority = security.OAuthAuthority(base) + self.runtime.authority = self.authority + self.client = self.authority.register({"redirect_uris": [self.params["redirect_uri"]]})["client_id"] + self.params.update(client_id=self.client, resource=self.authority.resource) + self.assertEqual(self.authority.issuer, base + const.OAUTH_PATH) + self.assertEqual(self.authority.resource, base + const.MCP_PATH) + resource_meta = await namespace["ResourceMetadataView"](self.hass).get(Request(method="GET")) + resource_meta = json.loads(resource_meta.text) + self.assertEqual(resource_meta["resource"], self.authority.resource) + self.assertEqual(resource_meta["authorization_servers"], [self.authority.issuer]) + server_meta = await namespace["ServerMetadataView"](self.hass).get(Request(method="GET")) + server_meta = json.loads(server_meta.text) + self.assertEqual(server_meta["issuer"], self.authority.issuer) + for key in ("authorization_endpoint", "token_endpoint", "registration_endpoint"): + self.assertTrue(server_meta[key].startswith(self.authority.issuer + "/")) + for wrong in ("https://ha.example.org", "https://ha.example.org:8126", + "https://other.example.org:8125"): + response = await self.view.get(Request(method="GET", query={ + **self.params, "resource": wrong + const.MCP_PATH})) + self.assertEqual(response.status, 400) + self.assertEqual(self.authority.pending, {}) + + form = await self.view.get(Request(method="GET", query=self.params)) + cookie = next(iter(form.cookies.values())) + self.assertTrue(cookie["secure"]) + self.assertTrue(cookie["httponly"]) + self.assertEqual(cookie["samesite"], "Lax") + self.assertEqual(form.headers["Content-Security-Policy"], + "default-src 'none'; form-action 'self' https://grok.com; base-uri 'none'; frame-ancestors 'none'") + transaction, pair = cookie.value, self.authority.pair() + form_data = {"transaction": transaction, "pairing_secret": pair} + cookies = {page_module.transaction_cookie_name(transaction): transaction} + for wrong in ("https://ha.example.org", "https://ha.example.org:8126", + "https://ha.example.org:8125.evil.org", "null"): + with self.subTest(origin=wrong), self.assertRaises(web.HTTPForbidden): + await self.view.post(Request(form=form_data, cookies=cookies, headers={"Origin": wrong})) + self.assertIn(transaction, self.authority.pending) + approval = await self.view.post(Request(form=form_data, cookies=cookies, headers={"Origin": base})) + self.assertEqual(approval.status, 303) + code = dict(parse_qsl(urlsplit(approval.headers["Location"]).query))["code"] + token_form = {"grant_type": "authorization_code", "code": code, "client_id": self.client, + "redirect_uri": self.params["redirect_uri"], "resource": self.authority.resource, + "code_verifier": self.verifier} + bad = await self.token_view.post(Request(form={**token_form, + "resource": "https://ha.example.org:8126" + const.MCP_PATH})) + self.assertEqual(bad.status, 400) + tokens = await self.token_view.post(Request(form=token_form)) + self.assertEqual(tokens.status, 200) + tokens = json.loads(tokens.text) + header = "Bearer " + tokens["access_token"] + self.assertIsNotNone(self.authority.authenticate(header)) + changed = security.OAuthAuthority("https://ha.example.org:8126", self.authority.snapshot()) + self.assertIsNone(changed.authenticate(header)) + mcp = namespace["MCPView"](self.hass) + self.assertIsNotNone(mcp.authorize(Request(headers={"Authorization": header, "Origin": base}))) + with self.assertRaises(web.HTTPForbidden): + mcp.authorize(Request(headers={"Authorization": header, "Origin": "https://ha.example.org:8126"})) + rotated = await self.token_view.post(Request(form={"grant_type": "refresh_token", + "refresh_token": tokens["refresh_token"], "client_id": self.client, + "resource": self.authority.resource})) + self.assertEqual(rotated.status, 200) + self.assertIsNone(self.authority.authenticate(header)) + + async def test_existing_nabu_grants_survive_default_port_canonicalization(self): + transaction = await self.form() + _, code, _ = self.authority.approve(transaction, self.authority.pair()) + tokens = self.authority.token({"grant_type": "authorization_code", "code": code, + "client_id": self.client, "redirect_uri": self.params["redirect_uri"], + "resource": self.authority.resource, "code_verifier": self.verifier}) + restored = security.OAuthAuthority(self.authority.base_url + ":443/", self.authority.snapshot()) + self.assertEqual(restored.base_url, self.authority.base_url) + self.assertIsNotNone(restored.authenticate("Bearer " + tokens["access_token"])) + def test_callback_cannot_inject_a_csp_directive_or_allow_other_domains(self): headers = namespace["form_headers_for_callback"] for callback in ("https://evil.example/callback", "http://grok.com/callback", diff --git a/tests/test_security.py b/tests/test_security.py index 5cd7d59..14bade1 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -58,6 +58,25 @@ def test_https_origin_with_optional_port(self): with self.subTest(value=value): self.assertEqual(security.public_base(value), expected) + def test_origin_rejects_parser_normalization_and_ambiguous_authorities(self): + for value in ("\nhttps://ha.example.org", " https://ha.example.org", "\x00https://ha.example.org", + "https://ha.exam\tple.org", "https://ha.example.org\r\n", + "https://@ha.example.org", "https://:@ha.example.org", + "https://ha.example.org:", "https://ha.example.org:0", + "https://ha.example.org?", "https://ha.example.org#", + "https://0x7f.0.0.1", "https://example.0x7f", + "https://ha.example.org\\evil", "https://ha.example.org:65536", + "https://[::1]", "https://ha.example.org.evil/path", + "https://" + "a" * 64 + ".example.org", None, b"https://ha.example.org"): + with self.subTest(value=value), self.assertRaises(ValueError): + security.public_base(value) + for value, expected in (("https://HA.Example.ORG:443/", "https://ha.example.org"), + ("https://ha.example.org:8125/", "https://ha.example.org:8125"), + ("https://ha.example.org:65535", "https://ha.example.org:65535"), + ("https://ha.xn--bcher-kva.de", "https://ha.xn--bcher-kva.de")): + with self.subTest(value=value): + self.assertEqual(security.public_base(value), expected) + def test_redirect_allowlist_and_exact_match(self): for uri in ("https://grok.com.evil.example/cb", "https://evil-grok.com/cb", "http://grok.com/cb", "https://grok.com/cb#fragment",