diff --git a/README.md b/README.md index 7e9c750..67fa611 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ Install the integration to your Home Assistant instance: ## Basic Usage -The integration does not proxy anything by default. There are two methods to actually proxy: +The integration does not proxy anything by default. There are two methods to actually +proxy: ### Set up static URL Proxying @@ -38,17 +39,23 @@ Visit the options configuration for the integration: - Navigate `Settings -> Devices & Services` - Click through `Home Assistant Web Proxy` in the list of installed integrations - Click `CONFIGURE` -- Click `+ ADD` to add a URL pattern that should be allowed proxy through the integration (e.g. `https://cam-*.mydomain.io` to allow proxying any hostname that starts with `cam-` in the `mydomain.io` domain) +- Click `+ ADD` to add a URL pattern that should be allowed proxy through the integration + (e.g. `https://cam-*.mydomain.io` to allow proxying any hostname that starts with + `cam-` in the `mydomain.io` domain) - Click `SUBMIT` Result: -- If the example target to proxy is `http://cam-back-yard.mydomain.io`, first URL encode it to `http%3A%2F%2Fcam-back-yard.mydomain.io` -- Visiting `https://$HA_INSTANCE/api/hass_web_proxy/v0/?url=http%3A%2F%2Fcam-back-yard.mydomain.io` will proxy through Home Assistant for authenticated Home Assistant users. +- If the example target to proxy is `http://cam-back-yard.mydomain.io`, first URL encode + it to `http%3A%2F%2Fcam-back-yard.mydomain.io` +- Visiting + `https://$HA_INSTANCE/api/hass_web_proxy/v0/?url=http%3A%2F%2Fcam-back-yard.mydomain.io` + will proxy through Home Assistant for authenticated Home Assistant users. ### Create a dynamic URL proxy -With this method, the user, Home Assistant automation or Lovelace cards, can dynamically request a URL be proxied: +With this method, the user, Home Assistant automation or Lovelace cards, can dynamically +request a URL be proxied: - Call the `hass_web_proxy.create_proxied_url` action: @@ -61,8 +68,11 @@ data: Result: -- If the example target to proxy is `http://cam-back-yard.mydomain.io`, first URL encode it to `http%3A%2F%2Fcam-back-yard.mydomain.io` -- Visiting `https://$HA_INSTANCE/api/hass_web_proxy/v0/?url=http%3A%2F%2Fcam-back-yard.mydomain.io` will proxy through Home Assistant for authenticated Home Assistant users. +- If the example target to proxy is `http://cam-back-yard.mydomain.io`, first URL encode + it to `http%3A%2F%2Fcam-back-yard.mydomain.io` +- Visiting + `https://$HA_INSTANCE/api/hass_web_proxy/v0/?url=http%3A%2F%2Fcam-back-yard.mydomain.io` + will proxy through Home Assistant for authenticated Home Assistant users. To delete the proxied URL: @@ -94,14 +104,15 @@ action: hass_web_proxy.create_proxied_url data: [...] ``` -| Name | Default | Description | -| ------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `open_limit` | | An optional number of times a URL pattern may be proxied to before it is automatically removed as a proxied URL. | -| `ssl_verification` | `true` | Whether SSL certifications/hostnames should be verified on the proxy URL targets. | -| `ssl_ciphers` | `default` | Whether to use `default`, `modern`, `intermediate`, or `insecure` ciphers. Older devices may not support default or modern ciphers. | -| `ttl` | | An optional number of seconds to allow proxying of this URL pattern. | -| `url_pattern` | | An required [URL pattern](https://github.com/jessepollak/urlmatch) to allow proxying for, e.g. `http://cam-*.mydomain.io`. | -| `url_id` | | An optional ID that can be used to refer to that proxied URL later (e.g. to delete it with the `hass_web_proxy.delete_proxied_url` action). | +| Name | Default | Description | +| ----------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open_limit` | | An optional number of times a URL pattern may be proxied to before it is automatically removed as a proxied URL. | +| `ssl_verification` | `true` | Whether SSL certifications/hostnames should be verified on the proxy URL targets. | +| `ssl_ciphers` | `default` | Whether to use `default`, `modern`, `intermediate`, or `insecure` ciphers. Older devices may not support default or modern ciphers. | +| `ttl` | | An optional number of seconds to allow proxying of this URL pattern. | +| `url_pattern` | | An required [URL pattern](https://github.com/jessepollak/urlmatch) to allow proxying for, e.g. `http://cam-*.mydomain.io`. | +| `url_id` | | An optional ID that can be used to refer to that proxied URL later (e.g. to delete it with the `hass_web_proxy.delete_proxied_url` action). | +| `allow_unauthenticated` | `false` | If `false`, or unset, unauthenticated HA users will not be allowed to access the proxied URL. If `true`, they will. See [Security Considerations](#user-content-security). | #### `hass_web_proxy.delete_proxied_url` @@ -118,16 +129,22 @@ data: [...] ### Security -No URLs are proxied by default. However, any user, automation or Javascript with -access to the Home Assistant instance could call -`hass_web_proxy.create_proxied_url` to create a dynamically proxied URL, thus -exposing arbitrary resources "behind" Home Assistant to anything/anyone that can -access Home Assistant itself. Depending on the setup, this may present an access -escalation beyond what would usually be accessible. +No URLs are proxied by default. + +However, any user, automation or Javascript with access to the Home Assistant +instance could call `hass_web_proxy.create_proxied_url` to create a dynamically +proxied URL, thus exposing arbitrary resources "behind" Home Assistant to +**anything/anyone that can access Home Assistant itself. Depending on the setup, +this may present an access escalation beyond what would usually be accessible. +In particular, wide exposure could occur if the user, automation or Javascript +set `allow_unauthenticated` in the dynamically proxied URL request, which would +allow arbitrary internet traffic to be proxied via the Home Assistant instance +regardless of whether or not they have valid user credentials on the HA +instance. ### Performance -All proxying is done by the integration which runs as part of the Home Assistant -process. As such, this proxy is not expected to be particularly performant and -excessive usage could slow Home Assistant itself down. This is unlikely to be -noticeable in practice for casual usage. +All proxying is done by the integration which runs as part of the Home Assistant process. +As such, this proxy is not expected to be particularly performant and excessive usage +could slow Home Assistant itself down. This is unlikely to be noticeable in practice for +casual usage. diff --git a/custom_components/hass_web_proxy/const.py b/custom_components/hass_web_proxy/const.py index ba43541..7c8a601 100644 --- a/custom_components/hass_web_proxy/const.py +++ b/custom_components/hass_web_proxy/const.py @@ -17,6 +17,7 @@ type HASSWebProxySSLCiphers = Literal["insecure", "modern", "intermediate", "default"] +CONF_ALLOW_UNAUTHENTICATED = "allow_unauthenticated" CONF_DYNAMIC_URLS: Final = "dynamic_urls" CONF_OPEN_LIMIT: Final = "open_limit" CONF_TTL: Final = "ttl" diff --git a/custom_components/hass_web_proxy/data.py b/custom_components/hass_web_proxy/data.py index c7d6aed..17987bf 100644 --- a/custom_components/hass_web_proxy/data.py +++ b/custom_components/hass_web_proxy/data.py @@ -19,6 +19,7 @@ class DynamicProxiedURL: ssl_ciphers: str open_limit: int expiration: int + allow_unauthenticated: bool type HASSWebProxyConfigEntry = ConfigEntry[HASSWebProxyData] diff --git a/custom_components/hass_web_proxy/proxy.py b/custom_components/hass_web_proxy/proxy.py index 04013a8..8caa32f 100644 --- a/custom_components/hass_web_proxy/proxy.py +++ b/custom_components/hass_web_proxy/proxy.py @@ -34,6 +34,7 @@ ) from .const import ( + CONF_ALLOW_UNAUTHENTICATED, CONF_DYNAMIC_URLS, CONF_OPEN_LIMIT, CONF_SSL_CIPHERS, @@ -73,6 +74,7 @@ ), vol.Optional(CONF_OPEN_LIMIT, default=1): cv.positive_int, vol.Optional(CONF_TTL, default=60): cv.positive_int, + vol.Optional(CONF_ALLOW_UNAUTHENTICATED, default=False): cv.boolean, }, required=True, ) @@ -109,6 +111,7 @@ def create_proxied_url(call: ServiceCall) -> None: ssl_ciphers=call.data["ssl_ciphers"], open_limit=call.data["open_limit"], expiration=time.time() + ttl if ttl else 0, + allow_unauthenticated=call.data["allow_unauthenticated"], ) def delete_proxied_url(call: ServiceCall) -> None: @@ -196,6 +199,7 @@ def _get_proxied_url(self, request: web.Request) -> ProxiedURL: return ProxiedURL( url=url_to_proxy, + allow_unauthenticated=proxied_url.allow_unauthenticated, ssl_context=self._get_ssl_context(proxied_url.ssl_ciphers) if proxied_url.ssl_verification else self._get_ssl_context_no_verify(proxied_url.ssl_ciphers), diff --git a/custom_components/hass_web_proxy/services.yaml b/custom_components/hass_web_proxy/services.yaml index 65ee076..2e5decb 100644 --- a/custom_components/hass_web_proxy/services.yaml +++ b/custom_components/hass_web_proxy/services.yaml @@ -56,7 +56,10 @@ create_proxied_url: min: 0 max: 100000 unit_of_measurement: seconds - + allow_unauthenticated: + name: Allow Unauthenticated + description: Whether or not to allow unauthenticated traffic to be proxied. + required: false delete_proxied_url: name: Delete a proxied URL description: > diff --git a/tests/test_proxy.py b/tests/test_proxy.py index fbb0aae..cb37004 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -11,6 +11,7 @@ from homeassistant.exceptions import ServiceValidationError from custom_components.hass_web_proxy.const import ( + CONF_ALLOW_UNAUTHENTICATED, CONF_DYNAMIC_URLS, CONF_OPEN_LIMIT, CONF_SSL_CIPHERS, @@ -136,12 +137,12 @@ async def test_proxy_view_ssl_insecure_no_verify( assert resp.status == HTTPStatus.OK -async def test_proxy_view_dynamic_url_ok( +async def test_proxy_view_dynamic_url_success( hass: HomeAssistant, local_server: Any, hass_client: Any, ) -> None: - """Test that a valid dynamic URL causes OK.""" + """Test that a valid dynamic URL proxies successfully.""" config_entry = create_mock_hass_web_proxy_config_entry(hass, TEST_OPTIONS) await setup_mock_hass_web_proxy_config_entry(hass, config_entry) @@ -292,3 +293,60 @@ async def test_proxy_view_dynamic_url_ttl( f"/api/hass_web_proxy/v0/?url={urllib.parse.quote_plus(str(local_server))}" ) assert resp.status == HTTPStatus.GONE + + +async def test_proxy_view_dynamic_url_unauthenticated_forbidden( + hass: HomeAssistant, + local_server: Any, + hass_client_no_auth: Any, +) -> None: + """Test that a valid dynamic URL is rejected for unauthorized users.""" + config_entry = create_mock_hass_web_proxy_config_entry(hass, TEST_OPTIONS) + + await setup_mock_hass_web_proxy_config_entry(hass, config_entry) + await async_proxy_setup_entry(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_PROXIED_URL, + { + **TEST_SERVICE_CALL_PARAMS, + CONF_URL_PATTERN: str(local_server), + }, + blocking=True, + ) + + unauthenticated_hass_client = await hass_client_no_auth() + resp = await unauthenticated_hass_client.get( + f"/api/hass_web_proxy/v0/?url={urllib.parse.quote_plus(str(local_server))}" + ) + assert resp.status == HTTPStatus.UNAUTHORIZED + + +async def test_proxy_view_dynamic_url_unauthenticated_permitted( + hass: HomeAssistant, + local_server: Any, + hass_client_no_auth: Any, +) -> None: + """Test that a valid dynamic URL is permitted for unauthorized users.""" + config_entry = create_mock_hass_web_proxy_config_entry(hass, TEST_OPTIONS) + + await setup_mock_hass_web_proxy_config_entry(hass, config_entry) + await async_proxy_setup_entry(hass, config_entry) + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_PROXIED_URL, + { + **TEST_SERVICE_CALL_PARAMS, + CONF_URL_PATTERN: str(local_server), + CONF_ALLOW_UNAUTHENTICATED: True, + }, + blocking=True, + ) + + unauthenticated_hass_client = await hass_client_no_auth() + resp = await unauthenticated_hass_client.get( + f"/api/hass_web_proxy/v0/?url={urllib.parse.quote_plus(str(local_server))}" + ) + assert resp.status == HTTPStatus.OK