From 69e48ad2c3fcf5da3aec89ce50db1df47c26c87f Mon Sep 17 00:00:00 2001 From: Miroslav Dvorak Date: Tue, 6 Oct 2026 10:27:07 +0000 Subject: [PATCH 1/2] feat: restyle OAuth pairing page with brand logo and light/dark theme --- custom_components/grok_connector/http.py | 4 +- .../grok_connector/oauth_page.py | 42 ++++++++++++++++--- docs/ARCHITECTURE.md | 2 +- tests/test_http.py | 9 +++- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/custom_components/grok_connector/http.py b/custom_components/grok_connector/http.py index 993a082..e183866 100644 --- a/custom_components/grok_connector/http.py +++ b/custom_components/grok_connector/http.py @@ -16,7 +16,7 @@ from .gateway import PROTOCOLS from .diagnostics import category, emit from .security import OAuthError, allowed_callback, digest -from .oauth_page import render_authorization, transaction_cookie_name +from .oauth_page import STYLE_SOURCE, render_authorization, transaction_cookie_name from .i18n import browser_language, text NO_CACHE = {"Cache-Control": "no-store", "Pragma": "no-cache", "X-Content-Type-Options": "nosniff"} @@ -41,7 +41,7 @@ def form_headers_for_callback(callback): raise OAuthError("invalid_client") origin = "https://" + host + (":443" if parsed.port == 443 else "") return {**FORM_HEADERS, "Content-Security-Policy": - "default-src 'none'; form-action 'self' " + origin + + "default-src 'none'; style-src " + STYLE_SOURCE + "; form-action 'self' " + origin + "; base-uri 'none'; frame-ancestors 'none'"} diff --git a/custom_components/grok_connector/oauth_page.py b/custom_components/grok_connector/oauth_page.py index f8efd58..263ba54 100644 --- a/custom_components/grok_connector/oauth_page.py +++ b/custom_components/grok_connector/oauth_page.py @@ -1,11 +1,38 @@ +# custom_components/grok_connector/oauth_page.py """No-script localized consent document with per-transaction secure cookies.""" +import base64 import hashlib import html import re +from pathlib import Path from .const import OAUTH_PATH from .i18n import browser_language, text +# Brand icon inlined: the page CSP forbids scripts and external images, inline SVG needs neither. +LOGO = (Path(__file__).parent / 'brand/icon.svg').read_text(encoding='utf-8') +# Light by default, dark via the browser preference; HA blue is the accent colour. +STYLE = ''' +:root{color-scheme:light dark;--bg:#f4f6f8;--card:#fff;--fg:#1c2733;--muted:#5f6b7a;--line:#dbe1e8;--accent:#03a9f4;--accent-fg:#fff;--soft:#e8f6fd} +@media(prefers-color-scheme:dark){:root{--bg:#111518;--card:#1c2228;--fg:#e6ebf0;--muted:#9aa6b4;--line:#333d47;--accent:#18bcf2;--accent-fg:#06202b;--soft:#15303d}} +*{box-sizing:border-box} +body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} +main{width:100%;max-width:440px;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:32px 28px;box-shadow:0 4px 24px rgba(0,0,0,.08)} +.logo{text-align:center}.logo svg{width:84px;height:84px} +h1{font-size:1.35rem;line-height:1.3;text-align:center;margin:12px 0 16px} +.badge{background:var(--soft);border-radius:10px;padding:10px 14px;font-size:.92rem;margin:0 0 14px} +.muted{color:var(--muted);font-size:.9rem;margin:0 0 14px} +.muted strong{display:block;color:var(--fg);font:.85rem ui-monospace,monospace;word-break:break-all;margin-top:2px} +label{display:block;font-weight:600;margin:18px 0 6px} +input{width:100%;padding:12px 14px;font:1.1rem ui-monospace,monospace;letter-spacing:.06em;color:var(--fg);background:var(--bg);border:2px solid var(--line);border-radius:10px} +input:focus{outline:none;border-color:var(--accent)} +button{width:100%;margin-top:16px;padding:13px;font-family:inherit;font-weight:600;font-size:1rem;color:var(--accent-fg);background:var(--accent);border:0;border-radius:10px;cursor:pointer} +button:hover{filter:brightness(1.08)}button:focus-visible{outline:3px solid var(--fg);outline-offset:2px} +footer{margin-top:20px;text-align:center;color:var(--muted);font-size:.8rem} +''' +# CSP source allowing exactly this stylesheet (no 'unsafe-inline'); the page CSP is otherwise default-src 'none'. +STYLE_SOURCE = "'sha256-" + base64.b64encode(hashlib.sha256(STYLE.encode()).digest()).decode() + "'" + def transaction_cookie_name(transaction): if not isinstance(transaction,str) or not re.fullmatch(r'[A-Za-z0-9_-]{43}',transaction): @@ -21,11 +48,14 @@ def t(key): read_count=len(runtime.policy.readable),control_count=len(runtime.policy.actions)) # Do not publish the home's entity IDs, names or states on an anonymous page. page = f''' - -{t('page_title')}

{t('page_title')}

-

{html.escape(count)}

{t('callback')} {html.escape(callback)}

-

{t('pair_help')}

+ +{t('page_title')} +

{t('page_title')}

+

{html.escape(count)}

+

{t('callback')}{html.escape(callback)}

+

{t('pair_help')}

- -

{t('community')}

''' + + +
{t('community')}
''' return page diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec4e666..d368d28 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ The reading list is a data-disclosure boundary, not an action-target boundary. A `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. +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. The page's single inline stylesheet (brand logo, light/dark theme via `prefers-color-scheme`) is allowed by a `style-src` SHA-256 hash computed from the exact stylesheet text, so the CSP stays `default-src 'none'` without `unsafe-inline`. 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. Each grant lasts at most 30 days, with short access tokens and refresh rotation. Pairing/authorization codes cannot be reused. A refresh-token replay invalidates the token family. Changing the origin also prevents stored credentials from matching the new resource. diff --git a/tests/test_http.py b/tests/test_http.py index b67c15d..dce41af 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -85,6 +85,7 @@ def json_response(value, *, status=200, headers=None): "web": web, "HomeAssistantView": object, "OAuthError": security.OAuthError, "allowed_callback": security.allowed_callback, "render_authorization":page_module.render_authorization, + "STYLE_SOURCE":page_module.STYLE_SOURCE, "transaction_cookie_name":page_module.transaction_cookie_name, "browser_language":i18n.browser_language,"text":i18n.text, "digest": security.digest, "emit": diagnostics.emit, "category": diagnostics.category, @@ -417,7 +418,10 @@ async def test_cross_origin_submission_stays_forbidden(self): async def test_form_permits_only_registered_callback_origin_and_keeps_other_protections(self): response = await self.view.get(Request(method="GET", query=self.params)) csp = response.headers["Content-Security-Policy"] - self.assertEqual(csp, "default-src 'none'; form-action 'self' https://grok.com; base-uri 'none'; frame-ancestors 'none'") + expected_csp = ("default-src 'none'; style-src " + page_module.STYLE_SOURCE + + "; form-action 'self' https://grok.com; base-uri 'none'; frame-ancestors 'none'") + self.assertEqual(csp, expected_csp) + self.assertNotIn("unsafe-inline", csp) self.assertNotIn("*", csp) self.assertNotIn("https://x.ai", csp) self.assertTrue(next(iter(response.cookies.values()))["secure"]) @@ -454,7 +458,8 @@ async def test_custom_https_port_preserves_oauth_origin_and_resource_boundary(se 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'") + "default-src 'none'; style-src " + page_module.STYLE_SOURCE + + "; 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} From 03dae9005160aedc0cdae3481dcfd62279af1fe6 Mon Sep 17 00:00:00 2001 From: Jan Pruha Date: Tue, 6 Oct 2026 14:20:59 +0200 Subject: [PATCH 2/2] Verify consent-page CSP and prepare release 2026.10.6.1 --- CHANGELOG.md | 9 +++++ README.md | 6 +++- custom_components/grok_connector/const.py | 2 +- .../grok_connector/manifest.json | 2 +- docs/SECURITY-ASSESSMENT-2026-10-06.md | 22 ++++++++++++ docs/VALIDATION.md | 8 +++++ tests/test_http.py | 34 +++++++++++++++++++ 7 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 docs/SECURITY-ASSESSMENT-2026-10-06.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 57bb1af..f17eda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2026.10.6.1 — 2026-10-06 + +- Restyle the OAuth pairing page with the connector logo, a responsive card and automatic light/dark themes. +- Permit only the exact static stylesheet using a SHA-256 CSP hash; scripts and external assets remain blocked. +- Preserve all five languages, HTML escaping, count-only anonymous disclosure and the existing pairing flow. +- Add regressions that independently hash the served stylesheet in every language and check the inline logo for active content or external references. + +Updating preserves configured permissions and OAuth grants. This release passed 91 Python tests, 5 JavaScript tests and package checks. See the [focused security review](docs/SECURITY-ASSESSMENT-2026-10-06.md); no live HA deployment or Grok/Tesla acceptance test was performed by the maintainer for this release. + ## 2026.10.5.2 — 2026-10-05 - Expose action tools with readable ASCII names derived from their configured names, such as `otevrit_branu`, while retaining their original titles and descriptions. diff --git a/README.md b/README.md index a797098..6296a39 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ 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.5.2** or a later stable release. Stable releases do not require beta versions to be enabled in HACS. +2. Download **2026.10.6.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 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. @@ -146,3 +146,7 @@ python tools/check_package.py Tests use simulated states/actions and adapters for HA/aiohttp. They never connect to a house or control devices. GitHub workflows additionally run hassfest and HACS validation after publication. [Release procedure](docs/RELEASING.md) · [MIT license](LICENSE). Home Assistant and Grok names/marks belong to their respective owners. No endorsement or affiliation is implied. + +## Pairing page appearance + +From **2026.10.6.1**, the OAuth pairing page displays the connector logo and follows the browser's light/dark preference in a responsive layout. The pairing process and all five translations are unchanged. Only the fixed stylesheet is allowed by its CSP hash; no scripts or external assets are introduced. See the [focused security review](docs/SECURITY-ASSESSMENT-2026-10-06.md). diff --git a/custom_components/grok_connector/const.py b/custom_components/grok_connector/const.py index 8eca377..aaf1692 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.5.2' +VERSION = '2026.10.6.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 57389c3..003ceab 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.5.2" + "version": "2026.10.6.1" } diff --git a/docs/SECURITY-ASSESSMENT-2026-10-06.md b/docs/SECURITY-ASSESSMENT-2026-10-06.md new file mode 100644 index 0000000..fcc8c1f --- /dev/null +++ b/docs/SECURITY-ASSESSMENT-2026-10-06.md @@ -0,0 +1,22 @@ +# OAuth pairing-page styling security assessment + +Date: 2026-10-06. Scope: [PR #5](https://github.com/Jenik5/GrokConnectorForHomeAssistant/pull/5), original head `69e48ad2c3fcf5da3aec89ce50db1df47c26c87f`, against `c0d96e78ad0cef25135ede6b00f5bc73922cb114` (2026.10.5.2). + +## Decision + +Accept the change. The reviewed diff adds presentation without broadening Grok's permissions or weakening authentication. The CSP adds one SHA-256 stylesheet hash, a deliberate narrow exception to the previous no-style policy. It does not introduce `unsafe-inline`, a stylesheet host allowlist or script permission. This is a focused source and regression review, not a full penetration test. + +## Reviewed boundaries + +- The stylesheet is a fixed packaged string. Its hash is computed from that string and emitted in the authorization form's CSP. It contains no imports or external resource references, no user input and no secrets. Hash authorization follows the [CSP element/source matching algorithm](https://www.w3.org/TR/CSP3/#match-element-to-source-list). +- `default-src 'none'`, `base-uri 'none'`, `frame-ancestors 'none'` and the verified callback origin in `form-action` remain in place. Scripts, external images, fonts, frames and network connections gain no permission. Error responses retain their original stricter CSP. +- The SVG comes from the fixed local `brand/icon.svg` path, with no request-controlled filename or remote fetch. The reviewed file contains only SVG paths/groups, drawing attributes and solid colors; no scripts, event handlers, links, external references or embedded HTML. +- Callback and transaction values and translations remain HTML escaped. The public page still discloses entity/action counts only, never their identifiers, names or states. No third-party assets, analytics or new logging are added. +- POST target, password input, pairing-secret requirements, transaction cookies, Origin checks, callback/resource binding, PKCE, token issuance/rotation and anti-replay behavior are unchanged. Authorization success and rejection regressions remain in the full suite. +- Entity/action selection, fixed action arguments, policy revocation and grant-bound MCP sessions are unchanged. Updating this release does not itself require fresh pairing. + +## Verification and limits + +Two added regressions independently check the served style hash and complete CSP in every supported language, and parse the served logo to prevent active markup or external references. Existing escaping, disclosure, cookie/Origin, OAuth, replay, policy and session tests remain covered. Local result: **91 Python tests**, **5 JavaScript tests**, package checks and JavaScript syntax validation passed. + +The tests run shipped modules with framework adapters and simulated actions. The contributor reports headless Chrome light/dark rendering with the real CSP and a live custom-origin hash check; these were not independently repeated by the maintainer. No live HA deployment, TLS/proxy audit, physical command or Grok/Tesla client acceptance test was performed in this review. Browser/layout acceptance remains a separate check. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 8f37900..5e78f7b 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -188,3 +188,11 @@ PR [#4](https://github.com/Jenik5/GrokConnectorForHomeAssistant/pull/4) replaces The maintainer fix reserves status and legacy names, avoids all literal slugs during suffix allocation, and rechecks generated names until unique. Retry identity uses the immutable action ID. The fixed implementation passed **89 Python tests**, **5 JavaScript tests** and package checks. Coverage includes all 64 configured actions with empty/non-ASCII/truncated/reserved names, calling each action through readable and legacy names, mixed-alias concurrent retries, authorization and argument rejection, and private HTTP diagnostic categories. These names follow the [MCP tool naming guidance](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names). The tests use actual connector modules with framework adapters and simulated action runners. No household action, live HA update or Grok/Tesla display test was performed for this release preparation. The contributor's report of live use is separate from maintainer-verified evidence. Existing OAuth, origin/port checks, policy revocation and session-isolation regressions remain covered. + +## Styled OAuth pairing page — 2026-10-06 / 2026.10.6.1 + +PR [#5](https://github.com/Jenik5/GrokConnectorForHomeAssistant/pull/5) adds the packaged logo, responsive layout and automatic light/dark themes. The focused security review found no change to authentication, authorization, redirect validation, token handling, entity/action permissions or session boundaries. See the [assessment](SECURITY-ASSESSMENT-2026-10-06.md). + +The maintainer added two regressions: one independently hashes the served stylesheet and checks the exact CSP and privacy headers in all five languages; the other parses the inline SVG and permits only passive geometry, without scripts, event handlers, style attributes or external references. The full local suite passed **91 Python tests**, **5 JavaScript tests**, package checks and JavaScript syntax validation. + +Tests exercise shipped connector modules with framework adapters and simulated actions. No maintainer live HA update, browser rendering test, physical action or Grok/Tesla acceptance test was performed for this release. The contributor's reported headless Chrome rendering and live custom-origin test remain separate evidence. Updating alone keeps existing configuration and grants because neither their schemas nor the policy/origin handling changed. diff --git a/tests/test_http.py b/tests/test_http.py index dce41af..52dd3e4 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -19,6 +19,7 @@ import sys import types import unittest +import xml.etree.ElementTree as ET from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit ROOT = Path(__file__).resolve().parents[1] / "custom_components/grok_connector" @@ -428,6 +429,39 @@ 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_stylesheet_hash_matches_served_page_in_every_language(self): + for language in catalogs: + with self.subTest(language=language): + response = await self.view.get(Request(method="GET", query=self.params, + headers={"Accept-Language": language})) + styles = re.findall(r"", response.text, re.S) + self.assertEqual(len(styles), 1) + # Hash the served text independently, not the module's CSP constant. + source = "'sha256-" + base64.b64encode(hashlib.sha256(styles[0].encode("utf-8")).digest()).decode() + "'" + directives = dict(part.strip().split(" ", 1) for part in + response.headers["Content-Security-Policy"].split(";")) + self.assertEqual(directives, {"default-src": "'none'", "style-src": source, + "form-action": "'self' https://grok.com", "base-uri": "'none'", "frame-ancestors": "'none'"}) + self.assertNotRegex(styles[0], r"(?i)@import|url\s*\(") + self.assertEqual(response.headers["Cache-Control"], "no-store") + self.assertEqual(response.headers["Referrer-Policy"], "same-origin") + self.assertIn('', response.text) + + async def test_inline_logo_is_passive_local_geometry(self): + response = await self.view.get(Request(method="GET", query=self.params)) + logos = re.findall(r"", response.text, re.S) + self.assertEqual(len(logos), 1) + logo = ET.fromstring(logos[0]) + namespace = "{http://www.w3.org/2000/svg}" + attributes = {"width", "height", "viewBox", "d", "fill", "stroke", "stroke-width", + "stroke-linecap", "stroke-linejoin", "transform"} + for element in logo.iter(): + self.assertIn(element.tag, {namespace + name for name in ("svg", "g", "path")}) + self.assertLessEqual(set(element.attrib), attributes) + for value in element.attrib.values(): + self.assertNotRegex(value, r"(?i)url\s*\(|javascript:|https?:|data:") + self.assertNotRegex(response.text, r"(?i)<(?:script|img|iframe|object|link)\b|\bon\w+\s*=|\sstyle\s*=") + async def test_custom_https_port_preserves_oauth_origin_and_resource_boundary(self): base = "https://ha.example.org:8125" self.authority = security.OAuthAuthority(base)