Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 166 additions & 12 deletions homeassistant/components/auth/indieauth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Helpers to resolve client ID/secret."""

from html.parser import HTMLParser
from http import HTTPStatus
from ipaddress import ip_address
import json
import logging
from typing import override
from urllib.parse import ParseResult, urljoin, urlparse
Expand All @@ -14,6 +16,9 @@

_LOGGER = logging.getLogger(__name__)

# We limit reads of a client_id page to the first 10kB.
MAX_FETCH_BYTES = 10240


async def verify_redirect_uri(
hass: HomeAssistant, client_id: str, redirect_uri: str
Expand All @@ -24,7 +29,10 @@ async def verify_redirect_uri(
except ValueError:
return False

redirect_parts = _parse_url(redirect_uri)
try:
redirect_parts = _parse_url(redirect_uri)
except ValueError:
return False

# Verify redirect url and client url have same scheme and domain.
is_valid = (
Expand Down Expand Up @@ -53,7 +61,15 @@ async def verify_redirect_uri(
# IndieAuth 4.2.2 allows for redirect_uri to be on different domain
# but needs to be specified in link tag when fetching `client_id`.
redirect_uris = await fetch_redirect_uris(hass, client_id)
return redirect_uri in redirect_uris
if redirect_uri in redirect_uris:
return True
_LOGGER.debug(
"redirect_uri %s is not among the advertised redirect uris %s for client_id %s",
redirect_uri,
redirect_uris,
client_id,
)
return False


class LinkTagParser(HTMLParser):
Expand All @@ -63,7 +79,7 @@ def __init__(self, rel: str) -> None:
"""Initialize a link tag parser."""
super().__init__()
self.rel = rel
self.found: list[str | None] = []
self.found: list[str] = []

@override
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
Expand All @@ -73,48 +89,115 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None

attributes: dict[str, str | None] = dict(attrs)

if attributes.get("rel") == self.rel:
self.found.append(attributes.get("href"))
# Skip tags with a missing or empty href: urljoin resolves those to
# the client_id URL itself instead of naming a redirect target.
if attributes.get("rel") == self.rel and (href := attributes.get("href")):
self.found.append(href)


def _reject_json_constant(constant: str) -> None:
"""Reject NaN/Infinity/-Infinity, which RFC 8259 does not allow."""
raise ValueError(f"Invalid JSON constant: {constant}")


def _is_valid_metadata_client_id(url: str) -> bool:
"""Validate a client_id URL for the metadata-document fallback.

The client identifier URL must be https with a path component and no
fragment (a bare trailing # counts as a fragment component). The remaining
client identifier rules are enforced upstream by _parse_client_id.
"""
try:
parts = urlparse(url)
# urlparse defers port validation until the attribute is accessed.
_ = parts.port
except ValueError:
return False
return parts.scheme == "https" and bool(parts.path) and "#" not in url


def _is_valid_metadata_redirect_uri(redirect_uri: str) -> bool:
"""Validate a client ID metadata document redirect_uris entry.

Entries must be absolute, fragment-free URIs: a non-empty scheme (so
private-use schemes like app:/callback stay valid) and no fragment per
RFC 6749 3.1.2 (a bare trailing # counts as a fragment component).
"""
try:
parts = urlparse(redirect_uri)
# urlparse defers port validation until the attribute is accessed.
_ = parts.port
except ValueError:
return False
return bool(parts.scheme) and "#" not in redirect_uri


async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
"""Find link tag with redirect_uri values.
"""Find the redirect_uri values that a client_id advertises.

We support two formats, checked in this order:

IndieAuth 4.2.2

The client SHOULD publish one or more <link> tags or Link HTTP headers with
a rel attribute of redirect_uri at the client_id URL.

We limit to the first 10kB of the page.
OAuth Client ID Metadata Document
(draft-ietf-oauth-client-id-metadata-document)

The client_id URL returns a JSON document with a redirect_uris array. As we
advertise client_id_metadata_document_supported in the authorization server
metadata, we fall back to this format when no link tags are found.

We read roughly the first 10kB of the page and a fetch error yields no
redirect uris.

We do not implement extracting redirect uris from headers.
"""
parser = LinkTagParser("redirect_uri")
chunks = 0
body: bytes = b""
status: int | None = None
redirected = False
try:
async with (
aiohttp.ClientSession() as session,
session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp,
):
status = resp.status
redirected = bool(resp.history)
async for data in resp.content.iter_chunked(1024):
parser.feed(data.decode())
chunks += 1
body += data

if chunks == 10:
if len(body) >= MAX_FETCH_BYTES:
break

except TimeoutError:
_LOGGER.error("Timeout while looking up redirect_uri %s", url)
return []
except aiohttp.client_exceptions.ClientSSLError:
_LOGGER.error("SSL error while looking up redirect_uri %s", url)
return []
except aiohttp.client_exceptions.ClientOSError as ex:
_LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror)
return []
except aiohttp.client_exceptions.ClientConnectionError:
_LOGGER.error(
"Low level connection error while looking up redirect_uri %s", url
)
return []
except aiohttp.client_exceptions.ClientError:
_LOGGER.error("Unknown error while looking up redirect_uri %s", url)
return []

if redirect_uris := _parse_link_tag_redirect_uris(url, body):
return redirect_uris

return _parse_metadata_document_redirect_uris(url, body, status, redirected)


def _parse_link_tag_redirect_uris(url: str, body: bytes) -> list[str]:
"""Find <link rel="redirect_uri"> values in the client_id page body."""
parser = LinkTagParser("redirect_uri")
parser.feed(body.decode(errors="replace"))

# Authorization endpoints verifying that a redirect_uri is allowed for use
# by a client MUST look for an exact match of the given redirect_uri in the
Expand All @@ -123,6 +206,77 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
return [urljoin(url, found) for found in parser.found]


def _parse_metadata_document_redirect_uris(
url: str, body: bytes, status: int | None, redirected: bool
) -> list[str]:
"""Parse the client_id page body as an OAuth Client ID Metadata Document.

Per draft-ietf-oauth-client-id-metadata-document the document only counts
when the client_id URL is https with a path and no fragment, the response
was a direct 200 (not redirected), the document's client_id round-trips,
and every redirect_uris entry is an absolute, fragment-free URI matched
exactly. The url and its document are client-controlled and fetched
unauthenticated, so rejections log at DEBUG (higher levels would be a
log-flood vector).
"""
# A body at the read cap may be truncated; a truncated prefix must not be
# trusted even if it happens to be parseable.
if (
len(body) >= MAX_FETCH_BYTES
or status != HTTPStatus.OK
or redirected
or not _is_valid_metadata_client_id(url)
):
_LOGGER.debug(
"Not treating %s as a client ID metadata document: body length %s,"
" status %s, redirected %s (client_id must be a fragment-free https"
" URL with a path)",
url,
len(body),
status,
redirected,
)
return []

try:
# Strict decode (RFC 8259 requires UTF-8): the link tag parser's
# lenient replacement decode would mask invalid bytes as U+FFFD.
document = json.loads(body.decode(), parse_constant=_reject_json_constant)
except UnicodeDecodeError:
_LOGGER.debug("Client ID metadata document at %s is not valid UTF-8", url)
return []
except ValueError:
_LOGGER.debug("Client ID metadata document at %s is not valid JSON", url)
return []

if not isinstance(document, dict):
_LOGGER.debug("Client ID metadata document at %s is not a JSON object", url)
return []

if document.get("client_id") != url:
_LOGGER.debug(
"Client ID metadata document at %s client_id does not match the"
" document URL",
url,
)
return []

# redirect_uris entries are returned unmodified for RFC 6749 exact matching
# rather than resolving relative references.
redirect_uris = document.get("redirect_uris")
if not isinstance(redirect_uris, list) or not all(
isinstance(redirect_uri, str) and _is_valid_metadata_redirect_uri(redirect_uri)
for redirect_uri in redirect_uris
):
_LOGGER.debug(
"Client ID metadata document at %s has missing or invalid redirect_uris",
url,
)
return []

return redirect_uris


def verify_client_id(client_id: str) -> bool:
"""Verify that the client id is valid."""
try:
Expand Down
11 changes: 5 additions & 6 deletions homeassistant/components/auth/login_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,11 @@ async def get(self, request: web.Request) -> web.Response:
"authorization_endpoint": f"{url_prefix}/auth/authorize",
"token_endpoint": f"{url_prefix}/auth/token",
"revocation_endpoint": f"{url_prefix}/auth/revoke",
# Home Assistant already accepts URL-based client_ids via
# IndieAuth without prior registration, which is compatible with
# draft-ietf-oauth-client-id-metadata-document. This flag
# advertises that support to encourage clients to use it. The
# metadata document is not actually fetched as IndieAuth doesn't
# require it.
# Home Assistant accepts URL-based client_ids via IndieAuth without
# prior registration, and discovers allowed redirect URIs from link
# tags or a Client ID Metadata Document served at the client_id URL.
# This flag advertises that support
# (draft-ietf-oauth-client-id-metadata-document).
"client_id_metadata_document_supported": True,
"response_types_supported": ["code"],
"service_documentation": (
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/bluetooth/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"bleak-retry-connector==4.6.3",
"bluetooth-adapters==2.4.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"bluetooth-data-tools==1.29.21",
"dbus-fast==5.0.22",
"habluetooth==6.26.5"
"habluetooth==6.26.7"
]
}
4 changes: 3 additions & 1 deletion homeassistant/components/esphome/entry_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,9 @@ def async_on_disconnect(self) -> None:
"""
self.available = False
if self.bluetooth_device:
self.bluetooth_device.available = False
# Fails pending BLE slot waiters and clears the dead
# session's allocations in addition to closing the gate.
self.bluetooth_device.async_set_unavailable()
# Make a copy since calling the disconnect callbacks
# may also try to discard/remove themselves.
for disconnect_cb in self.disconnect_callbacks.copy():
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/esphome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"requirements": [
"aioesphomeapi==45.12.0",
"esphome-dashboard-api==1.4.0",
"bleak-esphome==3.9.7"
"bleak-esphome==4.0.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
2 changes: 1 addition & 1 deletion homeassistant/components/ld2410_ble/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.29.18", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.29.21", "ld2410-ble==0.1.1"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/led_ble/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.29.18", "led-ble==1.1.11"]
"requirements": ["bluetooth-data-tools==1.29.21", "led-ble==1.1.11"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/private_ble_device/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.29.18"]
"requirements": ["bluetooth-data-tools==1.29.21"]
}
11 changes: 6 additions & 5 deletions homeassistant/components/template/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ def __init__(
)

device_registry = dr.async_get(hass)
# Allow linking to a main or child device, but not to a composite device.
if (
device_id := config.get(CONF_DEVICE_ID)
) is not None and device_registry.async_is_composite_device_id(
device_id
) is False:
self.device_entry = device_registry.async_get(device_id)
(device_id := config.get(CONF_DEVICE_ID)) is not None
and (device_entry := device_registry.async_get(device_id)) is not None
and not device_registry.async_is_composite_device_id(device_id)
):
self.device_entry = device_entry

@property
@abstractmethod
Expand Down
6 changes: 3 additions & 3 deletions homeassistant/components/template/repairs.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ async def async_step_select_device(
errors: dict[str, str] = {}
if user_input is not None:
device_id = user_input.get(CONF_DEVICE_ID)
if (
device_id is None
or device_registry.async_is_composite_device_id(device_id) is False
if device_id is None or (
device_registry.async_get(device_id) is not None
and not device_registry.async_is_composite_device_id(device_id)
):
options = {**entry.options}
if device_id:
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/tplink/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def _async_call_update_attrs(self) -> None:
if self._attr_available:
_LOGGER.warning(
"Unable to read data for %s %s: %s",
self._device,
self._device.host,
self.entity_id,
ex,
)
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/unifi/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "silver",
"requirements": ["aiounifi==92"]
"requirements": ["aiounifi==93"]
}
Loading
Loading