| Version | Supported |
|---|---|
| 1.7.0 | ✅ Current release |
| 1.6.2 | ✅ Previous stable — security fixes only if trivially backportable |
| < 1.6.2 | ❌ No patches |
(Note: the 1.5 release was developed as 1.4.1-dev.N through dev.72 and renumbered late cycle as scope grew beyond a patch release. Docker tags with the 1.4.1-dev.N stamp remain pullable from GHCR but are superseded by the 1.5.x stable tags.)
If you discover a security vulnerability, please open a GitHub issue with:
- A description of the vulnerability
- Steps to reproduce
- The affected version(s)
- Any suggested fix (optional but appreciated)
For vulnerabilities you'd prefer not to disclose publicly, open a minimal placeholder issue asking for a private contact channel and the maintainer will follow up.
This project's security posture is documented in dev-plans/SECURITY_AUDIT.md, including:
- An explicit Threat Model section spelling out the six trust assumptions (browser, LAN, workers, Supervisor, operator, build-log provenance) and the items explicitly NOT accepted
- A supply chain threat model with current mitigation state
- An OWASP Top 10 (2021) assessment
- 21 individual findings (F-01 through F-21) with severity ratings and current status
- A "Post-audit mitigations" summary of everything shipped since the original 2026-03-29 audit
The stated threat model is a trusted home network behind Home Assistant's Ingress authentication. The server add-on relies on HA Ingress for UI authentication and a shared Bearer token for worker authentication. No open findings remain; F-18 (worker pip install hash-pinning) is marked WONTFIX as of 1.6.1 — see §F-18 in the audit for the rationale (single-version hash-pinning is defense-useless against lazy-install drift, since the committed version rarely matches the version actually requested at job time).
- Hash-pinned Python dependencies (
--require-hashes) in both server and client Docker images. Lockfiles regenerated viascripts/refresh-deps.sh. pip-audit+npm auditgating CI on every push — hard failures block merge.- Dependabot configured for pip × 2 (server + client), npm, docker × 2, and github-actions (weekly). Zero open alerts at release time.
- Base-image digest pinning — both the worker Dockerfile and the server's
ARG BUILD_FROMdefault pin the Python base image by@sha256:…. In production under Supervisor the server base is overridden viabuild.yaml(which Supervisor's currentbuild_fromregex doesn't accept digest pins for — tracked); the worker pin is authoritative for every standalone deployment. - Cosign-signed GHCR images (keyless / GitHub OIDC) — verify with:
cosign verify \ --certificate-identity-regexp 'https://github.com/weirded/distributed-esphome/.github/workflows/publish-.*\.yml@.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/weirded/esphome-dist-client:latest - CycloneDX SBOM attestations — every published image has a CycloneDX SBOM bound to its digest via
cosign attest --type cyclonedx. Inspect the component inventory withcosign verify-attestation --type cyclonedx ... | jq. - SHA-pinned GitHub Actions — every non-local
uses:in the workflow files is pinned to a 40-char commit SHA with a trailing# vN.M.Pversion comment. New invariant inscripts/check-invariants.shfails CI on any floating-tag reference. Dependabot bumps both the SHA and the version comment together. - PY-7 invariant — every
--ignore-vulninpip-auditmust carry an inline applicability assessment (why the fix can't be pulled in, whether our code exercises the vulnerable path, dated). Prevents silent CVE dismissals. - PY-8 invariant — every direct dep in
requirements.txtmust also appear inrequirements.lock. Enforced byscripts/check-invariants.shso a forgottenrefresh-deps.shfails CI instead of shipping a broken image. - PY-9 invariant — no macOS-only transitive packages (
pyobjc*,appnope) inrequirements.lock. Forces lockfile regeneration through the linux/amd64 Docker wrapper inscripts/refresh-deps.sh.
- Security response headers (CSP,
X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,Permissions-Policy,X-Frame-Options: SAMEORIGIN) on every UI response via a dedicated aiohttp middleware. Deliberately not applied to the/api/v1/*worker tier. - Path traversal prevention — all file-endpoint handlers route through
helpers.safe_resolve(). X-Ingress-Pathsanitization — the Supervisor-supplied header is regex-stripped to[/A-Za-z0-9._-]before being interpolated into the HTML<base href="…">. Defence-in-depth against a misconfigured reverse proxy.- Monaco editor bundled via Vite — no external CDN, eliminates a supply-chain vector and enables offline/air-gapped HA installations.
- dompurify pinned to a patched version via
package.jsonoverrides. Monaco's transitive dep tree kept the vulnerable 3.2.7 well past when 3.4.0 was released; we force the patched version directly rather than waiting on upstream.shadcnCLI is a devDependency so itshonotransitive never ships to users.
require_ha_authadd-on option — defaulttruein 1.5.0 (AU.7). Direct-port (:8765)/ui/api/*requests must carry a valid Bearer token or get401 Bearer realm="ESPHome Fleet". Ingress-tunneled access is unaffected (Supervisor injectsX-Ingress-Path).- Two Bearer shapes accepted: (a) the add-on's shared worker token — used automatically by the native HA integration's coordinator, which receives it via the Supervisor-discovery payload; (b) a Home Assistant long-lived access token, validated against the Supervisor's
/authendpoint. - Mutation attribution — when the request was authenticated, compile / pin / schedule / rename / delete log lines suffix the resolved user's name (
…enqueued by stefan), giving per-user audit trails in the add-on log. System-Bearer callers (the integration) attribute toesphome_fleet_integrationso you can distinguish system from user actions.
- Typed protocol (pydantic v2) with structured
ProtocolErrorresponses on malformed payloads.PROTOCOL_VERSIONgate rejects mismatched peers with a clear error. - Byte-identical
protocol.pybetween server and client, enforced bytests/test_protocol.py::test_server_and_client_protocol_files_are_identical— prevents wire-contract drift. - Log payload DoS guard —
/api/v1/jobs/{id}/logrejects bodies larger than ~2MB (log_payload_too_large→ HTTP 413) before aiohttp buffers the full input.
- AppArmor profile (attached, permissive-plus-targeted-denies) —
ha-addon/apparmor.txtships alongsideconfig.yamlwithapparmor: true; Supervisor loads the named profile on install/upgrade and the security-star card lights up. Be honest about what this buys. The broad allow rules (file,,capability,,network,,signal,,dbus,,unix,,mount,,pivot_root,) are functionally close tounconfinedat the kernel boundary — the Supervisor badge is cosmetic at that layer. What the profile does add is a set of targeted deny rules (1.6.1 PR #80 review) that block a compromised compile-time Python (ESPHome'sexternal_components/includes:/libraries:, the highest-leverage attack surface the add-on exposes) from reading host shadow files (/etc/shadow*,/etc/gshadow*), Supervisor secrets (/run/secrets/**), arbitrary process memory (/proc/*/mem), writing kernel sysctls, or ptracing across the container namespace. Tightening the permissive side (replacing unqualifiedfile,+capability,with specific path allowlists) is tracked against observed denial telemetry — the working allowlist varies by Python minor version + PlatformIO release + ESPHome toolchain, so pinning it eagerly would churn every upstream bump. stage: experimentalflag removed fromconfig.yaml— the add-on defaults to stable per Supervisor convention now that cosign signing, SBOM attestations, the AppArmor profile, and hash-pinned deps are all in place.- Privileged-flag rationale documented —
DOCS.mdhas a "Why this add-on requests these permissions" section with a concise reason for each non-default flag (host_network,hassio_api,homeassistant_api,auth_api,privileged: [NET_RAW]). The lower security star count has a written rationale store-page readers can check before install. NET_RAWcapability scoped to ICMP ping (1.7.0). The 1.7.0 ping diagnostic (POST /ui/api/targets/{filename}/ping) tries unprivileged datagram ICMP first and falls back to a raw-socket path for installs where the host'snet.ipv4.ping_group_rangedisables the unprivileged route (HAOS default). The capability is requested viaprivileged: [NET_RAW]inconfig.yamland is the only non-default Linux capability the add-on holds. The endpoint is under/ui/api/*(HA Ingress /require_ha_authBearer-gated, same authorisation tier as every other UI mutation) and is rate-bounded by ICMP's owncount=10, timeout=2 sshape.
- Structured 401 reasons (
missing_authorization_header,authorization_not_bearer_scheme,bearer_token_mismatch) logged at WARNING with the peer IP for every worker-tier auth refusal. - IPv6-aware peer IP normalization — IPv6 zone IDs stripped, IPv4-mapped IPv6 unwrapped,
peername=Nonehandled without crashing. - Token file least-privilege —
/data/auth_tokenis written with0600so even a world-readable/datavolume mount on the host can't leak the worker-tier bearer.
These are accepted risks within the home-network threat model; see the full audit for rationale:
- HTTP between workers and server (not HTTPS). Users with remote workers across network segments should front the server with their own reverse proxy.
- Bearer token visible to the browser (required for the Connect Worker modal's
docker runcommand UX). - Direct-port
/ui/api/*Bearer required by default (AU.7). If a user flipsrequire_ha_auth: falsedeliberately — for an isolated test harness, say — they're opting out of this default and the old "relies only on HA Ingress" trust model applies. secrets.yamldelivered to every build worker — filtered since 1.6.2 (workers receive only the!secretkeys the bundled target actually references, courtesy of ESPHome 2026.4's built-in bundle format; the fullsecrets.yamlis no longer shipped). Workers are trusted per the threat model; this narrows the blast radius when a worker is on a less-trusted host.- Build workers can execute
external_components:/includes:/libraries:Python during compile — core ESPHome feature, accepted because workers are trusted. - Worker-to-worker job-result authorization isn't checked on
submit_result/update_status— any authenticated worker can submit results for any job. Accepted because workers are trusted.
All 21 audit findings are now FIXED, WONTFIX-by-threat-model, or marked INFO. Cycle deltas for 1.7.0 (no F-* status flips):
NET_RAWcapability added (DM.2 / #206). The new ICMP ping diagnostic needs raw ICMP sockets on installs wherenet.ipv4.ping_group_rangeis empty (HAOS default1 0).config.yamldeclaresprivileged: [NET_RAW]; the helper tries the unprivileged datagram path first and only falls back to raw sockets when the kernel refuses the safer path. Scoped to ICMP — the endpoint accepts no arbitrary host, only resolves the configured device's OTA address throughdevice_poller.resolve_ota_address. The capability is the only non-default Linux capability the add-on holds.- Bundle-creation race eliminated (#111). Before 1.7.0, parallel job claims could race ESPHome's
git.clone_or_update(which is not safe under concurrent invocation) and surface partial-state trees as misleading validation errors in build logs. 1.7.0 wraps every bundle subprocess behind a server-wideasyncio.Lock, eliminating both the sporadic build-log error confusion and the (lower-probability) race-window in which a concurrently-extractingexternal_componentscheckout could leak intermediate state into another job's bundle. Filed upstream against ESPHome. - Bundle-failure log scrubbed of ESPHome logger chatter (#112). Job-failure messages used to be decorated with whatever ESPHome's own
_LOGGERprinted during the validation subprocess (deprecation warnings, INFO chatter, the upstream2026.4.3false-positiveIncluding a single package under \packages:` is deprecatedfor the dict-of-stringspackages:` form). Now only our explicit error writes — and uncaught Python tracebacks — reach the captured stream. Defensive: the chatter wasn't sensitive but the noise around it concealed real diagnostics. - Rendered-config endpoint never logs the rendered body (RC.1). The new
GET /ui/api/targets/{filename}/rendered-configreturns a fully-resolved YAML with plaintext!secretsubstitutions; the handler logs only the byte count (rendered config bytes=NN), never the body. A regression test (tests/test_rendered_config.py::test_rendered_config_logs_do_not_leak_output) pins this contract — log scrubbing is not optional. Output stripping ANSI conceal sequences is byte-stripped server-side so a downstream Monaco render never displays raw escape codes that would otherwise leaksecret:/key:/psk:values into the visible YAML. - Git working tree clean after every UI file mutation (#197 / PY-11). New invariant: every
/config/esphome/mutation endpoint must leavegit status --porcelainempty afterdrain_pending_commits()returns. Before 1.7.0, archive (os.unlink) and rename (git mv) endpoints could leave dangling deletion entries staged but not committed, which the next user save would sweep into someone else'sgit logand produce an inaccurate rollback diff. 12-scenario regression test (tests/test_git_clean_after_ops.py) drives every file-mutating endpoint and asserts a clean tree. - Firmware retention + backup_exclude (#198 / #199). New
firmware_retention_daysSettings field (default 2) caps how long compile binaries linger on disk;firmware/added to the add-on'sbackup_excludeso HA snapshots no longer carry 200+ MB of regenerable binaries. Reduces the data exposure on a stolen / leaked HA backup tarball. - Tags + routing-rule endpoints (TG.*). Five new mutation endpoints (
POST /ui/api/workers/{id}/tags,GET / POST /ui/api/routing-rules,PUT / DELETE /ui/api/routing-rules/{id}). All under/ui/api/*so they inherit the existingrequire_ha_auth+ Ingress auth model — no new tokens, no new privileges, no widening of the trust boundary. Bodies are validated through pydantic models / typed dict shapes. Per-devicerouting_extraround-trips through the existingPOST /ui/api/targets/{filename}/meta(no new endpoint shape). - Disk-quota endpoint (DQ.5). New
POST /ui/api/workers/{id}/disk-quotaaccepts{disk_quota_bytes: int | null}with_validate_int_range(1 GiB, 1 TiB)server-side bound. Same auth tier; no new wire-protocol fields except additive optionalRegisterRequest.disk_quota_bytes/HeartbeatResponse.set_disk_quota_bytes/SystemInfo.{disk_usage_bytes,disk_quota_bytes,last_eviction_freed_bytes}(PROTOCOL_VERSIONunchanged — backward compatible).
Cycle deltas for 1.6.2:
- Job-bundle scope narrowed (BD). Before 1.6.2, every job claim shipped the full
/config/esphome/directory to the claiming worker —.git/(history + remote URLs + any wired-up push credentials), the completesecrets.yamlwith every device's Wi-Fi and API keys, and any in-place PlatformIO build caches. A worker operator on a less-trusted host effectively held read access to the entire fleet's secrets and git history. 1.6.2 switches to ESPHome's built-in bundle format (esphome.bundle.ConfigBundleCreator, ESPHome 2026.4+), which ships only the files the target's validated config references, withsecrets.yamlfiltered down to the keys the target actually uses..git/and cross-device secrets no longer leave the server by construction.
Cycle deltas for 1.6.1:
- F-13 (Docker base image digest pinning) moved OPEN → FIXED (partial) via SS.4. Worker Dockerfile pins
python:3.11-slim@sha256:…; server Dockerfile pins theARG BUILD_FROMdefault digest. Supervisor's production build path still can't carry a digest (upstreambuild_fromregex rejects@sha256:…); partial until that's relaxed. - F-18 (worker pip install hash-pinning) was marked FIXED (partial) in 1.5.0 via SC.3, then re-assessed and marked WONTFIX in 1.6.1: the single-version constraints file we committed rarely matched the version actually requested at job time (users routinely pin older ESPHome versions or track newer releases than we'd had time to generate constraints for), so the hardened
--require-hashespath's hit rate in practice was ~0% and the fallback-to-unpinned-install behavior was the load-bearing case. See §F-18 in the audit for the full re-assessment. - F-21 (add-on ran unconfined) added and immediately FIXED in the same cycle via SS.1 — AppArmor profile attached, Supervisor runs the container under confinement.
If your deployment doesn't match the trusted-home-network model, read the audit carefully before exposing the add-on.