From fb41efd9419cd37fb1e6b34f1cb6ff3745c389c1 Mon Sep 17 00:00:00 2001 From: Michaux Julien Date: Wed, 21 Oct 2026 09:25:02 +0200 Subject: [PATCH 1/3] Stop weigh-in photos from reading arbitrary host files - The photo decoder treated any string naming an existing file as a path to copy into the photo store. The weigh_in / batch/import websocket commands and the record_weight / import_batch actions are open to every logged-in user, and batch/export with include_photos handed the copy back, so a non-admin could read /config/secrets.yaml or anything under .storage. - _photo_bytes now only decodes inline data (data URL or base64); an empty payload yields no photo instead of an empty file. - record_weight keeps accepting a local file path, resolved in services.py only: hass.config.is_allowed_path must accept it (allowlist_external_dirs, the camera.snapshot rule), the file is read off the event loop and passed down as a data URL. Anything else raises HomeAssistantError. - Tests cover the allowed path, the refused path, the shared decoder and the websocket commands ignoring even an allowlisted path; services.yaml, README and CHANGELOG document the rule. Reported in the HACS default-catalog review. --- CHANGELOG.md | 19 ++++++ README.md | 18 ++++-- .../curing_chamber/coordinator.py | 16 +++-- custom_components/curing_chamber/services.py | 48 +++++++++++++- .../curing_chamber/services.yaml | 9 ++- custom_components/curing_chamber/websocket.py | 1 + tests/ha/test_photos.py | 13 ++++ tests/ha/test_services.py | 63 +++++++++++++++++++ tests/ha/test_websocket.py | 43 +++++++++++++ 9 files changed, 214 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6146fff..61a0696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Security +- **Weigh-in photos can no longer read arbitrary host files.** The photo + decoder treated any string naming an existing file as a path to copy into + the photo store, so any logged-in user (admin or not) could feed + `/config/secrets.yaml` or anything under `.storage` to the `record_weight` / + `import_batch` actions or the `weigh_in` / `batch/import` websocket + commands, then read it back with `batch/export` and `include_photos`. The + coordinator now only decodes inline data (data URL or base64). The + `record_weight` action still accepts a local file path, but checks it with + `hass.config.is_allowed_path` (`allowlist_external_dirs`, the rule + `camera.snapshot` follows) and refuses anything else; the websocket API and + `import_batch` never interpret a photo as a path. Reported during the HACS + default-catalog review (hacs/default#11574). + +### Changed +- README: the HACS install section leads with the default catalog (search for + *Curing Chamber* in HACS or use the My Home Assistant button); adding the + custom repository stays as the fallback. + ## [0.7.0] - 2026-10-08 ### Added diff --git a/README.md b/README.md index d93caf0..fa35747 100644 --- a/README.md +++ b/README.md @@ -360,14 +360,17 @@ data: batch_id: coppa_1 weight: 1080 note: "day 12" - photo: "/config/www/photos/coppa_day12.jpg" # path, base64 or data: URL + photo: "/config/www/photos/coppa_day12.jpg" # data: URL, base64 or a local file ``` Photos are stored **privately** under `/.storage/curing_chamber/photos///` and served only to logged-in users at `/api/curing_chamber/photo/…` (the panel and card sign the URL before displaying it). Photos saved by earlier versions under the public -`www/` folder are moved there automatically on startup. Other batch services: +`www/` folder are moved there automatically on startup. A file path is only +honoured by this action (never by the panel or the websocket API) and must lie +under Home Assistant's `allowlist_external_dirs` (`/www` and the media +folders by default), the same rule as `camera.snapshot`. Other batch services: `set_reference_batch`, `complete_batch`, `archive_batch`, `delete_batch`, `add_batch_event`, `delete_batch_event`, `export_batch`. @@ -853,7 +856,7 @@ data: batch_id: coppa_1 weight: 1080 note: "jour 12" - photo: "/config/www/photos/coppa_jour12.jpg" # chemin, base64 ou URL data: + photo: "/config/www/photos/coppa_jour12.jpg" # URL data:, base64 ou fichier local ``` Les photos sont stockées **en privé** sous @@ -861,9 +864,12 @@ Les photos sont stockées **en privé** sous uniquement aux utilisateurs connectés via `/api/curing_chamber/photo/…` (le panneau et la carte signent l'URL avant affichage). Les photos enregistrées par les versions précédentes dans le dossier public `www/` y sont déplacées -automatiquement au démarrage. Autres services de lot : `set_reference_batch`, -`complete_batch`, `archive_batch`, `delete_batch`, `add_batch_event`, -`delete_batch_event`, `export_batch`. +automatiquement au démarrage. Un chemin de fichier n'est accepté que par ce +service (jamais par le panneau ni l'API websocket) et doit se trouver sous +`allowlist_external_dirs` de Home Assistant (`/www` et les dossiers +média par défaut), même règle que `camera.snapshot`. Autres services de lot : +`set_reference_batch`, `complete_batch`, `archive_batch`, `delete_batch`, +`add_batch_event`, `delete_batch_event`, `export_batch`. **Saisir un poids depuis l'interface.** Chaque enceinte expose une entité `number` **Poids manuel** (`number._manual_weight`). Saisissez une diff --git a/custom_components/curing_chamber/coordinator.py b/custom_components/curing_chamber/coordinator.py index 32857de..ffb5198 100644 --- a/custom_components/curing_chamber/coordinator.py +++ b/custom_components/curing_chamber/coordinator.py @@ -106,20 +106,24 @@ def _opt_number(value: object) -> float | None: def _photo_bytes(photo: str) -> bytes | None: - """Decode a weigh-in photo supplied as a data URL, base64 or a local path.""" + """Decode a weigh-in photo supplied inline, as a data URL or bare base64. + + The value is never treated as a filesystem path: the websocket commands are + open to every logged-in user, so reading files here would let anyone copy + host files (secrets.yaml, .storage/...) into the photo store and fetch them + back through the export. The ``record_weight`` action resolves allowlisted + paths itself, in ``services.py``, before calling the coordinator. + """ if not photo: return None if photo.startswith("data:"): _, _, encoded = photo.partition(",") try: - return base64.b64decode(encoded) + return base64.b64decode(encoded) or None except (binascii.Error, ValueError): return None - if os.path.isfile(photo): - with open(photo, "rb") as handle: - return handle.read() try: - return base64.b64decode(photo, validate=True) + return base64.b64decode(photo, validate=True) or None except (binascii.Error, ValueError): return None diff --git a/custom_components/curing_chamber/services.py b/custom_components/curing_chamber/services.py index d17dba6..aa1a34c 100644 --- a/custom_components/curing_chamber/services.py +++ b/custom_components/curing_chamber/services.py @@ -2,6 +2,8 @@ from __future__ import annotations +import base64 +import binascii from typing import TYPE_CHECKING import voluptuous as vol @@ -147,6 +149,47 @@ _BATCH_ID_SCHEMA = vol.Schema({**_TARGET, vol.Required(_ATTR_BATCH_ID): cv.string}) +def _is_inline_photo(photo: str) -> bool: + """True for a data URL or a bare base64 string (what the panel and the card send).""" + if photo.startswith("data:"): + return True + try: + base64.b64decode(photo, validate=True) + except (binascii.Error, ValueError): + return False + return True + + +async def _async_load_photo(hass: HomeAssistant, photo: str) -> str: + """Resolve the ``photo`` field of ``record_weight``. + + Inline data is passed through untouched. Anything else is a local file + path: this action is the only entry point allowed to read one, and only + under ``allowlist_external_dirs`` (checked with ``is_allowed_path``, the + rule ``camera.snapshot`` follows). The file is handed to the coordinator as + a data URL so nothing below this layer ever opens a user-supplied path. + """ + if _is_inline_photo(photo): + return photo + shown = photo if len(photo) <= 120 else f"{photo[:117]}..." + + def _read() -> bytes: + # is_allowed_path does blocking I/O: both checks run off the event loop. + if not hass.config.is_allowed_path(photo): + raise HomeAssistantError( + f"Photo path '{shown}' is not under allowlist_external_dirs; " + "allow its folder in configuration.yaml or pass the photo as a data URL" + ) + with open(photo, "rb") as handle: + return handle.read() + + try: + data = await hass.async_add_executor_job(_read) + except OSError as err: + raise HomeAssistantError(f"Cannot read photo '{shown}': {err}") from err + return f"data:image/jpeg;base64,{base64.b64encode(data).decode()}" + + def _coordinators(hass: HomeAssistant, call: ServiceCall) -> list[CuringChamberCoordinator]: stored: dict[str, CuringChamberCoordinator] = hass.data.get(DOMAIN, {}) if not stored: @@ -327,6 +370,9 @@ async def import_batch(call: ServiceCall) -> ServiceResponse: async def record_weight(call: ServiceCall) -> None: moment = call.data.get(_ATTR_TIMESTAMP) timestamp = dt_util.as_utc(moment).timestamp() if moment is not None else None + photo = call.data.get(_ATTR_PHOTO) + if photo: + photo = await _async_load_photo(hass, photo) for coordinator in _coordinators(hass, call): try: await coordinator.async_record_weight( @@ -334,7 +380,7 @@ async def record_weight(call: ServiceCall) -> None: call.data[_ATTR_WEIGHT], timestamp=timestamp, note=call.data.get(_ATTR_NOTE), - photo=call.data.get(_ATTR_PHOTO), + photo=photo, ) except ValueError as err: raise HomeAssistantError(str(err)) from err diff --git a/custom_components/curing_chamber/services.yaml b/custom_components/curing_chamber/services.yaml index e79e5a4..d858592 100644 --- a/custom_components/curing_chamber/services.yaml +++ b/custom_components/curing_chamber/services.yaml @@ -390,9 +390,12 @@ record_weight: photo: name: Photo description: >- - Optional photo as a data URL, base64 string, or a local file path; - stored privately under /config/.storage/curing_chamber/photos and - served only to logged-in users at /api/curing_chamber/photo/... + Optional photo as a data URL or a base64 string. This action also + accepts a local file path, which must lie under allowlist_external_dirs + (/www and the media folders by default); the panel and the + websocket API never read files. Stored privately under + /config/.storage/curing_chamber/photos and served only to logged-in + users at /api/curing_chamber/photo/... selector: text: diff --git a/custom_components/curing_chamber/websocket.py b/custom_components/curing_chamber/websocket.py index 329cd26..d18296a 100644 --- a/custom_components/curing_chamber/websocket.py +++ b/custom_components/curing_chamber/websocket.py @@ -332,6 +332,7 @@ async def ws_batch_delete( vol.Required("weight"): vol.All(vol.Coerce(float), vol.Range(min=0)), vol.Optional("timestamp"): vol.Any(None, vol.Coerce(float)), vol.Optional("note"): vol.Any(None, str), + # Inline data only (data URL / base64); a file path is never read here. vol.Optional("photo"): vol.Any(None, str), } ) diff --git a/tests/ha/test_photos.py b/tests/ha/test_photos.py index 965e713..f9a9707 100644 --- a/tests/ha/test_photos.py +++ b/tests/ha/test_photos.py @@ -14,6 +14,7 @@ STORAGE_KEY_TEMPLATE, STORAGE_VERSION, ) +from custom_components.curing_chamber.coordinator import _photo_bytes from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -135,3 +136,15 @@ async def test_panel_and_card_are_registered_with_version( assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() remove_panel.assert_called_once_with(hass, "curing-chamber", warn_if_unknown=False) + + +async def test_photo_bytes_decodes_inline_data_only(tmp_path) -> None: + """The shared decoder understands data URLs and base64, never a file path.""" + secret = tmp_path / "secrets.yaml" + secret.write_text("api_key: hunter2\n", encoding="utf-8") + assert _photo_bytes(str(secret)) is None + assert _photo_bytes(PHOTO) == JPEG + assert _photo_bytes(base64.b64encode(JPEG).decode()) == JPEG + assert _photo_bytes("data:image/jpeg;base64,!!!") is None + assert _photo_bytes("not base64 at all") is None + assert _photo_bytes("") is None diff --git a/tests/ha/test_services.py b/tests/ha/test_services.py index a44b10b..92c0b54 100644 --- a/tests/ha/test_services.py +++ b/tests/ha/test_services.py @@ -143,6 +143,69 @@ async def test_record_weight_with_photo(hass: HomeAssistant, config_entry, seed_ assert not os.path.exists(hass.config.path("www", "curing_chamber", batch_id)) +async def test_record_weight_photo_from_allowed_path( + hass: HomeAssistant, config_entry, seed_states +) -> None: + """The action may read a local file, but only under allowlist_external_dirs.""" + coordinator = await _setup(hass, config_entry, seed_states) + await _create_batch(hass) + batch_id = next(iter(coordinator.batches)) + www = hass.config.path("www") + os.makedirs(www) + photo_file = os.path.join(www, "coppa.jpg") + with open(photo_file, "wb") as handle: + handle.write(b"\xff\xd8\xff\xd9") + hass.config.allowlist_external_dirs = {os.path.realpath(www)} + + await hass.services.async_call( + DOMAIN, + SERVICE_RECORD_WEIGHT, + {"batch_id": batch_id, "weight": 950.0, "photo": photo_file}, + blocking=True, + ) + await hass.async_block_till_done() + + sample = coordinator.batches[batch_id].latest_sample + assert sample.photo_url is not None + on_disk = coordinator.photo_path(batch_id, sample.photo_url.rsplit("/", 1)[-1]) + with open(on_disk, "rb") as handle: + assert handle.read() == b"\xff\xd8\xff\xd9" + + +async def test_record_weight_rejects_path_outside_allowlist( + hass: HomeAssistant, config_entry, seed_states +) -> None: + """A path outside allowlist_external_dirs is refused and nothing is copied.""" + coordinator = await _setup(hass, config_entry, seed_states) + await _create_batch(hass) + batch_id = next(iter(coordinator.batches)) + secret = hass.config.path("secrets.yaml") + with open(secret, "w", encoding="utf-8") as handle: + handle.write("api_key: hunter2\n") + hass.config.allowlist_external_dirs = {os.path.realpath(hass.config.path("www"))} + + with pytest.raises(HomeAssistantError, match="allowlist_external_dirs"): + await hass.services.async_call( + DOMAIN, + SERVICE_RECORD_WEIGHT, + {"batch_id": batch_id, "weight": 950.0, "photo": secret}, + blocking=True, + ) + await hass.async_block_till_done() + + assert coordinator.batches[batch_id].samples == [] + assert not os.path.exists(hass.config.path(".storage", DOMAIN, "photos")) + + # An allowed folder but a missing file is an error too, not a silent drop. + with pytest.raises(HomeAssistantError, match="Cannot read photo"): + await hass.services.async_call( + DOMAIN, + SERVICE_RECORD_WEIGHT, + {"batch_id": batch_id, "weight": 950.0, "photo": hass.config.path("www", "x.jpg")}, + blocking=True, + ) + + async def test_set_reference_batch_drives_completion( hass: HomeAssistant, config_entry, seed_states ) -> None: diff --git a/tests/ha/test_websocket.py b/tests/ha/test_websocket.py index e5c905b..d1562b0 100644 --- a/tests/ha/test_websocket.py +++ b/tests/ha/test_websocket.py @@ -496,3 +496,46 @@ async def test_batch_export_with_photos_and_import( msg = await _call(client, "batch/import", entry_id=entry_id, batch={"batch": {"samples": "x"}}) assert not msg["success"] assert msg["error"]["code"] == "invalid_format" + + +async def test_websocket_photos_never_read_local_files( + hass: HomeAssistant, config_entry, seed_states, hass_ws_client +) -> None: + """Over the websocket a photo is inline data only, even for an allowlisted path. + + Every logged-in user can call these commands, so a path must never be opened + and copied into the photo store (where ``batch/export`` would hand it back). + """ + coordinator = await _setup(hass, config_entry, seed_states) + client = await hass_ws_client(hass) + entry_id = config_entry.entry_id + secret = hass.config.path("secrets.yaml") + with open(secret, "w", encoding="utf-8") as handle: + handle.write("api_key: hunter2\n") + hass.config.allowlist_external_dirs = {os.path.realpath(hass.config.config_dir)} + batch = await _create_batch(client, entry_id) + + msg = await _call( + client, "weigh_in", entry_id=entry_id, batch_id=batch["id"], weight=980.0, photo=secret + ) + assert msg["success"], msg + assert msg["result"]["samples"][0]["photo_url"] is None + + record = { + "batch": { + "name": "Copy", + "samples": [{"timestamp": 1.0, "weight": 900.0, "photo_data": secret}], + } + } + msg = await _call(client, "batch/import", entry_id=entry_id, batch=record) + assert msg["success"], msg + assert msg["result"]["samples"][0]["photo_url"] is None + + for batch_id in (batch["id"], msg["result"]["id"]): + msg = await _call( + client, "batch/export", entry_id=entry_id, batch_id=batch_id, include_photos=True + ) + assert msg["success"], msg + assert "photo_data" not in msg["result"]["batch"]["samples"][0] + assert not os.path.exists(hass.config.path(".storage", DOMAIN, "photos")) + assert coordinator.batches[batch["id"]].samples[0].photo_url is None From daf8f11bacf38231a12f8623bde667aa251d65fc Mon Sep 17 00:00:00 2001 From: Michaux Julien Date: Wed, 21 Oct 2026 09:25:02 +0200 Subject: [PATCH 2/3] README: lead the HACS install section with the default catalog - Once Curing Chamber is in the default catalog users find it by searching HACS, so the steps start there (with a My Home Assistant button) and the custom-repository route becomes the fallback, in both languages. --- README.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fa35747..f565e4c 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,16 @@ diagnostics, and full FR/EN translations. ## Installation (HACS) -1. In HACS → *Integrations* → the three-dots menu → **Custom repositories**. -2. Add `https://github.com/ttiot/curingchamber` with category **Integration**. -3. Install **Curing Chamber**, then restart Home Assistant. -4. *Settings → Devices & Services → Add Integration → Curing Chamber*. +[![Open your Home Assistant instance and show this repository inside HACS.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=ttiot&repository=curingchamber&category=integration) + +1. In HACS, search for **Curing Chamber** and download it (or use the button + above). +2. Restart Home Assistant. +3. *Settings → Devices & Services → Add Integration → Curing Chamber*. + +Not listed in your HACS yet? Add `https://github.com/ttiot/curingchamber` as a +custom repository (HACS → three-dots menu → **Custom repositories**, category +**Integration**) and download it from there. Manual install: copy `custom_components/curing_chamber/` into your HA `config/custom_components/` folder and restart. @@ -631,10 +637,16 @@ d'action manuelle en mode dégradé, diagnostics, traductions FR/EN complètes. ## Installation (HACS) -1. HACS → *Intégrations* → menu ⋮ → **Dépôts personnalisés**. -2. Ajoutez `https://github.com/ttiot/curingchamber`, catégorie **Intégration**. -3. Installez **Curing Chamber**, puis redémarrez Home Assistant. -4. *Paramètres → Appareils et services → Ajouter une intégration → Curing Chamber*. +[![Ouvrir votre instance Home Assistant et afficher ce dépôt dans HACS.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=ttiot&repository=curingchamber&category=integration) + +1. Dans HACS, recherchez **Curing Chamber** et téléchargez-le (ou utilisez le + bouton ci-dessus). +2. Redémarrez Home Assistant. +3. *Paramètres → Appareils et services → Ajouter une intégration → Curing Chamber*. + +Pas encore visible dans votre HACS ? Ajoutez `https://github.com/ttiot/curingchamber` +en dépôt personnalisé (HACS → menu ⋮ → **Dépôts personnalisés**, catégorie +**Intégration**) puis téléchargez-le depuis là. Installation manuelle : copiez `custom_components/curing_chamber/` dans le dossier `config/custom_components/` de HA et redémarrez. From cb4cef74aad19b8a8abb918605373ef370d22153 Mon Sep 17 00:00:00 2001 From: Michaux Julien Date: Wed, 21 Oct 2026 09:25:03 +0200 Subject: [PATCH 3/3] Release 0.7.1 - Bump manifest.json and pyproject.toml to 0.7.1. - CHANGELOG: move the Unreleased entries under 0.7.1 and update the compare links. --- CHANGELOG.md | 5 ++++- custom_components/curing_chamber/manifest.json | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a0696..2f8a11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.7.1] - 2026-10-21 + ### Security - **Weigh-in photos can no longer read arbitrary host files.** The photo decoder treated any string naming an existing file as a path to copy into @@ -246,7 +248,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - FR/EN translations, HACS metadata, and CI (ruff, mypy, pytest+coverage, hassfest, HACS validation) on Python 3.13. -[Unreleased]: https://github.com/ttiot/curingChamber/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/ttiot/curingChamber/compare/v0.7.1...HEAD +[0.7.1]: https://github.com/ttiot/curingChamber/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/ttiot/curingChamber/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/ttiot/curingChamber/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/ttiot/curingChamber/compare/v0.4.0...v0.5.0 diff --git a/custom_components/curing_chamber/manifest.json b/custom_components/curing_chamber/manifest.json index 2882432..31974e3 100644 --- a/custom_components/curing_chamber/manifest.json +++ b/custom_components/curing_chamber/manifest.json @@ -16,5 +16,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/ttiot/curingchamber/issues", "requirements": [], - "version": "0.7.0" + "version": "0.7.1" } diff --git a/pyproject.toml b/pyproject.toml index aa7b259..2d98f53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "curing_chamber" -version = "0.7.0" +version = "0.7.1" description = "Home Assistant custom integration to drive a charcuterie curing chamber." readme = "README.md" requires-python = ">=3.11"