Skip to content

Commit

Permalink
feat: Support dynamic unauthenticated requests (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
dermotduffy authored Oct 31, 2024
1 parent 6ed86c8 commit f076c99
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 28 deletions.
67 changes: 42 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:

Expand All @@ -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:

Expand Down Expand Up @@ -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`

Expand All @@ -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.
1 change: 1 addition & 0 deletions custom_components/hass_web_proxy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions custom_components/hass_web_proxy/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class DynamicProxiedURL:
ssl_ciphers: str
open_limit: int
expiration: int
allow_unauthenticated: bool


type HASSWebProxyConfigEntry = ConfigEntry[HASSWebProxyData]
Expand Down
4 changes: 4 additions & 0 deletions custom_components/hass_web_proxy/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)

from .const import (
CONF_ALLOW_UNAUTHENTICATED,
CONF_DYNAMIC_URLS,
CONF_OPEN_LIMIT,
CONF_SSL_CIPHERS,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion custom_components/hass_web_proxy/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >
Expand Down
62 changes: 60 additions & 2 deletions tests/test_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

0 comments on commit f076c99

Please sign in to comment.