66import asyncio
77import contextlib
88import logging
9+ import re
910import sys
1011
1112from zeroconf import IPVersion , ServiceStateChange , Zeroconf
1213from zeroconf .asyncio import AsyncServiceBrowser , AsyncServiceInfo , AsyncZeroconf
1314
15+ from ._sanitize import safe_label_str
16+
1417FORMAT = "{: <7}|{: <32}|{: <15}|{: <12}|{: <16}|{: <10}|{: <32}"
1518COLUMN_NAMES = ("Status" , "Name" , "Address" , "MAC" , "Version" , "Platform" , "Board" )
1619UNKNOWN = "unknown"
1720
18-
19- def decode_bytes_or_unknown (data : str | bytes | None ) -> str :
20- """Decode bytes or return unknown."""
21+ # Per-column display caps for peer-supplied mDNS labels, derived from the
22+ # FORMAT widths so a hostile broadcaster can't widen a column by stuffing a
23+ # long value; deriving them from FORMAT keeps the caps in lock-step if the
24+ # table layout is ever retuned.
25+ _COLUMN_WIDTHS = tuple (int (w ) for w in re .findall (r"<\s*(\d+)" , FORMAT ))
26+ assert len (_COLUMN_WIDTHS ) == len (COLUMN_NAMES ), (
27+ "FORMAT width count must match COLUMN_NAMES; update one and the other together"
28+ )
29+ _MAX_NAME_DISPLAY = _COLUMN_WIDTHS [COLUMN_NAMES .index ("Name" )]
30+ _MAX_MAC_DISPLAY = _COLUMN_WIDTHS [COLUMN_NAMES .index ("MAC" )]
31+ _MAX_VERSION_DISPLAY = _COLUMN_WIDTHS [COLUMN_NAMES .index ("Version" )]
32+ _MAX_PLATFORM_DISPLAY = _COLUMN_WIDTHS [COLUMN_NAMES .index ("Platform" )]
33+ _MAX_BOARD_DISPLAY = _COLUMN_WIDTHS [COLUMN_NAMES .index ("Board" )]
34+
35+
36+ def decode_mdns_label_or_unknown (
37+ data : str | bytes | None , limit : int = _MAX_NAME_DISPLAY
38+ ) -> str :
39+ """Decode peer-supplied mDNS bytes, strip non-printables, length-cap."""
2140 if data is None :
2241 return UNKNOWN
2342 if isinstance (data , bytes ):
24- return data .decode ()
25- return data
43+ # A device on the LAN can broadcast arbitrary bytes; use "replace" so
44+ # a malformed UTF-8 payload doesn't raise out of the zeroconf callback.
45+ data = data .decode ("utf-8" , "replace" )
46+ return safe_label_str (data , limit )
2647
2748
2849def async_service_update (
@@ -32,15 +53,22 @@ def async_service_update(
3253 state_change : ServiceStateChange ,
3354) -> None :
3455 """Service state changed."""
35- short_name = name .partition ("." )[0 ]
56+ # The mDNS service name is peer-controlled — sanitize before printing so
57+ # a hostile broadcaster can't inject ANSI escapes / newlines / null bytes
58+ # into the terminal.
59+ short_name = safe_label_str (name .partition ("." )[0 ], _MAX_NAME_DISPLAY )
3660 state = "OFFLINE" if state_change is ServiceStateChange .Removed else "ONLINE"
3761 info = AsyncServiceInfo (service_type , name )
3862 info .load_from_cache (zeroconf )
3963 properties = info .properties
40- mac = decode_bytes_or_unknown (properties .get (b"mac" ))
41- version = decode_bytes_or_unknown (properties .get (b"version" ))
42- platform = decode_bytes_or_unknown (properties .get (b"platform" ))
43- board = decode_bytes_or_unknown (properties .get (b"board" ))
64+ mac = decode_mdns_label_or_unknown (properties .get (b"mac" ), _MAX_MAC_DISPLAY )
65+ version = decode_mdns_label_or_unknown (
66+ properties .get (b"version" ), _MAX_VERSION_DISPLAY
67+ )
68+ platform = decode_mdns_label_or_unknown (
69+ properties .get (b"platform" ), _MAX_PLATFORM_DISPLAY
70+ )
71+ board = decode_mdns_label_or_unknown (properties .get (b"board" ), _MAX_BOARD_DISPLAY )
4472 address = ""
4573 if addresses := info .ip_addresses_by_version (IPVersion .V4Only ):
4674 address = str (addresses [0 ])
0 commit comments