Skip to content

Sanitize peer-supplied mDNS labels in discover CLI#911

Merged
bdraco merged 2 commits into
mainfrom
port-mdns-label-sanitize-1689
May 20, 2026
Merged

Sanitize peer-supplied mDNS labels in discover CLI#911
bdraco merged 2 commits into
mainfrom
port-mdns-label-sanitize-1689

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 20, 2026

What does this implement/fix?

Ports esphome/aioesphomeapi#1689 to the dashboard's esphome-device-builder-discover CLI. Anything broadcasting _esphomebuilder._tcp.local. controls both the service instance name and every TXT value; the CLI was passing those bytes straight to stdout, so a hostile peer could embed ANSI escapes in server_version / pin_sha256 / etc. and reflow the operator's terminal, or break the column-aligned table with an oversized value.

Routes every peer-supplied label through _safe_label (strip non-printables, length-cap), switches the bytes path to decode("utf-8", "replace") so a malformed payload no longer raises out of the zeroconf callback, and renames _decode to _decode_mdns_label_or_unknown. Per-column caps are derived from _FORMAT widths via regex with a module-load assertion pinning _COLUMN_NAMES against the derived widths, so a future _FORMAT edit cannot silently drift from the caps.

Replaces and closes #897.

Related issue or feature (if applicable):

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — docs
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Frontend coordination

  • No frontend change needed
  • Companion frontend PR: esphome/device-builder-dashboard-frontend#

Checklist

  • The code change is tested and works locally.
  • Pre-commit hooks pass (ruff, codespell, yaml/json/python checks).
  • Tests have been added or updated under tests/ where applicable.
  • components.json has not been hand-edited (regenerate via script/sync_components.py if a sync is needed).
  • Architecture-level changes are reflected in docs/ARCHITECTURE.md and/or docs/API.md.

@bdraco bdraco added the bugfix Bug fix label May 20, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 20, 2026

Merging this PR will not alter performance

✅ 25 untouched benchmarks


Comparing port-mdns-label-sanitize-1689 (89f2442) with main (a15196a)

Open in CodSpeed

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 20, 2026

Codecov Report

❌ Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 99.29%. Comparing base (a15196a) to head (89f2442).

Files with missing lines Patch % Lines
esphome_device_builder/discover.py 94.73% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #911      +/-   ##
==========================================
- Coverage   99.30%   99.29%   -0.01%     
==========================================
  Files         191      191              
  Lines       14091    14099       +8     
==========================================
+ Hits        13993    14000       +7     
- Misses         98       99       +1     
Flag Coverage Δ
py3.12 99.24% <94.73%> (-0.01%) ⬇️
py3.14 99.29% <94.73%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
esphome_device_builder/discover.py 98.57% <94.73%> (-1.43%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bdraco bdraco requested a review from Copilot May 20, 2026 15:15
@bdraco
Copy link
Copy Markdown
Member Author

bdraco commented May 20, 2026

@bluetoothbot review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the esphome-device-builder-discover CLI against terminal escape/control-sequence injection by sanitizing all peer-supplied mDNS labels (service instance name + TXT values) before printing them to stdout.

Changes:

  • Add _safe_label and route all peer-supplied labels through printable-character filtering and length caps derived from _FORMAT column widths.
  • Decode TXT bytes with utf-8 using errors="replace" to prevent malformed payloads from raising inside the zeroconf callback.
  • Extend tests/test_discover.py with coverage for sanitization behavior (ESC/CR/LF/NUL/TAB stripping, length caps, unicode printable preservation) and format-width invariants.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
esphome_device_builder/discover.py Sanitizes mDNS instance/TXT labels, adds per-column caps derived from _FORMAT, and switches TXT decode to utf-8 with replacement.
tests/test_discover.py Adds tests covering sanitization, decode behavior, and verifies caps match _FORMAT widths.

Comment thread tests/test_discover.py Outdated
Comment thread esphome_device_builder/discover.py Outdated
@bdraco bdraco marked this pull request as ready for review May 20, 2026 15:31
@bdraco bdraco merged commit 49c258f into main May 20, 2026
13 checks passed
@bdraco bdraco deleted the port-mdns-label-sanitize-1689 branch May 20, 2026 15:31
@bluetoothbot
Copy link
Copy Markdown

PR Review — Sanitize peer-supplied mDNS labels in discover CLI

Solid security hardening port. The implementation routes every peer-controlled label through _safe_label (printable-only filter + length cap) before stdout, switches the bytes path to decode(..., 'replace') so malformed UTF-8 can't raise out of the zeroconf callback, and derives per-column caps from _FORMAT widths with a module-load RuntimeError (not assert, so the invariant holds under python -O) pinning _COLUMN_NAMES to the derived widths. The two earlier copilot nits — assert vs runtime check, and the stale 'leaves strings alone' docstring — are both already addressed in this revision (the docstring now says 'decodes / sanitizes bytes + str, marks missing'). Test coverage is thorough: ESC / CR / LF / NUL / TAB stripping on both str and bytes paths, default-cap behavior, Unicode-printable preservation, format-width invariant, and end-to-end _on_service_state_change assertions that hostile instance names and TXT values don't reach stdout. The 1 uncovered line flagged by codecov is presumably the RuntimeError branch which can't be exercised without reloading the module — not a blocker. Scope is correct: per the project memory, the remote_build/_mdns.py path has a different attack surface (frontend-rendered, not terminal) and is intentionally out of scope here. Merge-ready.



Checklist

  • Input validation at system boundaries (peer-supplied mDNS bytes)
  • No bare except / silent error swallow
  • Module-load invariant survives python -O
  • Tests cover ESC / CR / LF / NUL / TAB stripping, length caps, both str and bytes paths
  • Format-width / column-name drift guarded by both runtime check and test
  • Tests verify observable behavior, not source-code presence

Summary

Solid security hardening port. The implementation routes every peer-controlled label through _safe_label (printable-only filter + length cap) before stdout, switches the bytes path to decode(..., 'replace') so malformed UTF-8 can't raise out of the zeroconf callback, and derives per-column caps from _FORMAT widths with a module-load RuntimeError (not assert, so the invariant holds under python -O) pinning _COLUMN_NAMES to the derived widths. The two earlier copilot nits — assert vs runtime check, and the stale 'leaves strings alone' docstring — are both already addressed in this revision (the docstring now says 'decodes / sanitizes bytes + str, marks missing'). Test coverage is thorough: ESC / CR / LF / NUL / TAB stripping on both str and bytes paths, default-cap behavior, Unicode-printable preservation, format-width invariant, and end-to-end _on_service_state_change assertions that hostile instance names and TXT values don't reach stdout. The 1 uncovered line flagged by codecov is presumably the RuntimeError branch which can't be exercised without reloading the module — not a blocker. Scope is correct: per the project memory, the remote_build/_mdns.py path has a different attack surface (frontend-rendered, not terminal) and is intentionally out of scope here. Merge-ready.


Automated review by Kōanb099ca7
89f2442

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants