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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ 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
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
Expand Down Expand Up @@ -227,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
Expand Down
46 changes: 32 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -360,14 +366,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
`<config>/.storage/curing_chamber/photos/<entry_id>/<batch_id>/` 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` (`<config>/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`.

Expand Down Expand Up @@ -628,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.
Expand Down Expand Up @@ -853,17 +868,20 @@ 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
`<config>/.storage/curing_chamber/photos/<entry_id>/<batch_id>/` et servies
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 (`<config>/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.<enceinte>_manual_weight`). Saisissez une
Expand Down
16 changes: 10 additions & 6 deletions custom_components/curing_chamber/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion custom_components/curing_chamber/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/ttiot/curingchamber/issues",
"requirements": [],
"version": "0.7.0"
"version": "0.7.1"
}
48 changes: 47 additions & 1 deletion custom_components/curing_chamber/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import base64
import binascii
from typing import TYPE_CHECKING

import voluptuous as vol
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -327,14 +370,17 @@ 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(
call.data[_ATTR_BATCH_ID],
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
Expand Down
9 changes: 6 additions & 3 deletions custom_components/curing_chamber/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
(<config>/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:

Expand Down
1 change: 1 addition & 0 deletions custom_components/curing_chamber/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
13 changes: 13 additions & 0 deletions tests/ha/test_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
63 changes: 63 additions & 0 deletions tests/ha/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading