fix: Katalog-SSID-Cache threadsicher, Sicherheitshinweis zu mDNS/MAC

scan() konnte den SSID-Cache filtern, während contains_ssid() aus einem
anderen Executor-Thread gleichzeitig hineinschrieb ("dictionary changed
size during iteration"). Ein Lock schützt jetzt beide Zugriffe.

README ergänzt: mDNS-Updates auf eine bekannte MAC laufen ungeprüft: Ein
Gerät, das dieselbe MAC ankündigt, übernimmt den Host-Eintrag und könnte
so an Anfragen mit Token gelangen (nur in einem bereits kompromittierten
Netz relevant).
This commit is contained in:
Michael Sauer
2026-09-16 17:40:21 +02:00
parent b8f3c94278
commit fc29b0befe
2 changed files with 43 additions and 8 deletions
+3
View File
@@ -29,6 +29,9 @@ HA zeigt dafür einen Reparaturhinweis. Eigenes Token auf der Lampe setzen
*Neu konfigurieren* eintragen. *Neu konfigurieren* eintragen.
Wechselt eine Lampe ihre IP, aktualisiert mDNS den Eintrag selbst (Identität ist die MAC). Wechselt eine Lampe ihre IP, aktualisiert mDNS den Eintrag selbst (Identität ist die MAC).
Das gilt ungeprüft: Ein Gerät im selben Netz, das dieselbe MAC per mDNS ankündigt, übernimmt
damit den Host-Eintrag und kann so an Anfragen mit Token gelangen, die eigentlich an die echte
Lampe gehen (relevant nur in einem bereits kompromittierten Netz).
## Entitäten ## Entitäten
+40 -8
View File
@@ -11,6 +11,7 @@ import hashlib
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
import threading
import zlib import zlib
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -132,6 +133,9 @@ class FirmwareCatalog:
self.images: tuple[FirmwareImage, ...] = () self.images: tuple[FirmwareImage, ...] = ()
self._parsed: dict[tuple[str, int, int], FirmwareImage | None] = {} self._parsed: dict[tuple[str, int, int], FirmwareImage | None] = {}
self._ssid_hits: dict[tuple[str, int, int, str], bool] = {} self._ssid_hits: dict[tuple[str, int, int, str], bool] = {}
# scan() runs in one executor thread while contains_ssid() may run in another (e.g. an
# install's Wi-Fi re-check racing a periodic rescan): both read and mutate _ssid_hits.
self._ssid_hits_lock = threading.Lock()
def scan(self) -> tuple[FirmwareImage, ...]: def scan(self) -> tuple[FirmwareImage, ...]:
"""Blocking. Invalid files are logged once per (path, mtime, size).""" """Blocking. Invalid files are logged once per (path, mtime, size)."""
@@ -161,7 +165,8 @@ class FirmwareCatalog:
if image is not None: if image is not None:
found.append(image) found.append(image)
self._parsed = {k: v for k, v in self._parsed.items() if k in seen} self._parsed = {k: v for k, v in self._parsed.items() if k in seen}
self._ssid_hits = {k: v for k, v in self._ssid_hits.items() if k[:3] in seen} with self._ssid_hits_lock:
self._ssid_hits = {k: v for k, v in self._ssid_hits.items() if k[:3] in seen}
self.images = tuple(found) self.images = tuple(found)
return self.images return self.images
@@ -184,13 +189,40 @@ class FirmwareCatalog:
except OSError, InvalidImageError: except OSError, InvalidImageError:
return False return False
def verify_and_read(self, image: FirmwareImage) -> bytes | None:
"""Blocking. Like `verify`, but returns the exact bytes that were checked (or None).
Callers that need to act on the verified content (e.g. serve it, or scan it for an SSID)
must use these bytes rather than reading the file again afterwards - the file on disk can
change between the two reads.
"""
try:
stat = image.path.stat()
if (stat.st_mtime_ns, stat.st_size) != (image.mtime_ns, image.size):
return None
data = image.path.read_bytes()
except OSError:
return None
try:
if parse_trailer(data) != (image.device_type, image.version):
return None
except InvalidImageError:
return None
return data
def contains_ssid(self, image: FirmwareImage, ssid: str) -> bool: def contains_ssid(self, image: FirmwareImage, ssid: str) -> bool:
"""Blocking, cached per (image file, ssid).""" """Blocking, cached per (image file, ssid)."""
key = (str(image.path), image.mtime_ns, image.size, ssid) key = (str(image.path), image.mtime_ns, image.size, ssid)
if key not in self._ssid_hits: with self._ssid_hits_lock:
try: cached = key in self._ssid_hits
data = image.path.read_bytes() hit = self._ssid_hits.get(key)
except OSError: if cached:
return False return bool(hit)
self._ssid_hits[key] = contains_ssid(data, ssid) try:
return self._ssid_hits[key] data = image.path.read_bytes()
except OSError:
return False
hit = contains_ssid(data, ssid)
with self._ssid_hits_lock:
self._ssid_hits[key] = hit
return hit