Merge ~bjornt/maas:bug-1773698-observer-mdns-unicode into maas:master

Proposed by Björn Tillenius
Status: Merged
Approved by: Björn Tillenius
Approved revision: cf0fc2a30703ba95b910df1c689a65ec4341e8f2
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~bjornt/maas:bug-1773698-observer-mdns-unicode
Merge into: maas:master
Diff against target: 214 lines (+62/-42)
2 files modified
src/provisioningserver/utils/avahi.py (+11/-3)
src/provisioningserver/utils/tests/test_avahi.py (+51/-39)
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Abstain
MAAS Lander Approve
Blake Rouse (community) Approve
Review via email: mp+348222@code.launchpad.net

Commit message

LP: #1773698 - observer-mdns fails on unicode issue

The TXT field can contain binary. Instead of treating the whole avahi-browse
output as UTF-8, we first split it up and treat everything except the TXT field
as UTF-8.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b bug-1773698-observer-mdns-unicode lp:~bjornt/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: cf0fc2a30703ba95b910df1c689a65ec4341e8f2

review: Approve
Revision history for this message
Mike Pontillo (mpontillo) wrote :

This is a pretty good workaround (at least it stops the annoying tracebacks), but splitting the line before converting it to UTF-8 isn't a full solution. With this fix, an attacker can now use a TXT record containing a newline to purposely confuse MAAS by injecting a specific payload into an mDNS record. Since a newline isn't something that /only/ the avahi utilities will output, MAAS wouldn't know the difference, and will now silently accept the payload.

Luckily, the consequences of this wouldn't be catastrophic (since MAAS doesn't rely on mDNS data for its operation).

As pointed out in the inline comments, this needs to be fixed upstream.

review: Abstain

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/utils/avahi.py b/src/provisioningserver/utils/avahi.py
2index 69af5c1..743f60a 100644
3--- a/src/provisioningserver/utils/avahi.py
4+++ b/src/provisioningserver/utils/avahi.py
5@@ -88,9 +88,17 @@ def parse_avahi_event(line: str) -> dict:
6 # consistency with the event names used in the avahi code).
7 data = {}
8 # Limit to 9 fields here in case a ';' appears in the TXT record unescaped.
9- fields = line.rstrip().split(';', 9)
10+ fields = line.rstrip().split(b';', 9)
11 if len(fields) < 6:
12 return None
13+ for index, field in enumerate(fields):
14+ # the 9th field is TXT, which can contain anything, including
15+ # binary characters. There's a bug about this, that maybe
16+ # avahi-browser should escape those binary characters, which
17+ # would allow us to treat everything as utf-8:
18+ # https://github.com/lathiat/avahi/issues/169
19+ if index != 9:
20+ fields[index] = field.decode('utf-8')
21 event_type = fields[0]
22 # The type of the event is indicated in the first character from
23 # avahi-browse. The following fields (no matter the event type) will
24@@ -239,7 +247,7 @@ def _reader_from_avahi():
25 stdout=subprocess.PIPE)
26 try:
27 # Avahi says "All strings used in DNS-SD are UTF-8 strings".
28- yield io.TextIOWrapper(avahi_browse.stdout, encoding='utf-8')
29+ yield avahi_browse.stdout
30 finally:
31 # SIGINT or SIGTERM (see ActionScript.setup) has been received,
32 # avahi-browse may have crashed or been terminated, or there may have
33@@ -266,7 +274,7 @@ def _reader_from_stdin(stdin):
34 def _reader_from_file(filename):
35 """Reader from `filename`."""
36 # Avahi says "All strings used in DNS-SD are UTF-8 strings".
37- with open(filename, "r", encoding="utf-8") as infile:
38+ with open(filename, "rb") as infile:
39 yield infile
40
41
42diff --git a/src/provisioningserver/utils/tests/test_avahi.py b/src/provisioningserver/utils/tests/test_avahi.py
43index 96aca7f..d876ea7 100644
44--- a/src/provisioningserver/utils/tests/test_avahi.py
45+++ b/src/provisioningserver/utils/tests/test_avahi.py
46@@ -61,9 +61,9 @@ class TestParseAvahiEvent(MAASTestCase):
47
48 def test__parses_browser_new_event(self):
49 input = (
50- "+;eth0;IPv4"
51- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
52- "_http._tcp;local")
53+ b"+;eth0;IPv4"
54+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
55+ b"_http._tcp;local")
56 event = parse_avahi_event(input)
57 self.assertEquals(
58 event,
59@@ -79,9 +79,9 @@ class TestParseAvahiEvent(MAASTestCase):
60
61 def test__parses_browser_removed_event(self):
62 input = (
63- "-;eth0;IPv4"
64- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
65- "_http._tcp;local")
66+ b"-;eth0;IPv4"
67+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
68+ b"_http._tcp;local")
69 event = parse_avahi_event(input)
70 self.assertEquals(
71 event,
72@@ -97,13 +97,13 @@ class TestParseAvahiEvent(MAASTestCase):
73
74 def test__parses_resolver_found_event(self):
75 input = (
76- "=;eth0;IPv4"
77- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
78- "_http._tcp;local;"
79- "printer.local;"
80- "192.168.0.222;"
81- "80;"
82- '"priority=50" "rp=RAW"')
83+ b"=;eth0;IPv4"
84+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
85+ b"_http._tcp;local;"
86+ b"printer.local;"
87+ b"192.168.0.222;"
88+ b"80;"
89+ b'"priority=50" "rp=RAW"')
90 event = parse_avahi_event(input)
91 self.assertEquals(
92 event,
93@@ -118,12 +118,25 @@ class TestParseAvahiEvent(MAASTestCase):
94 'fqdn': 'printer.local',
95 'hostname': 'printer',
96 'port': '80',
97- 'txt': '"priority=50" "rp=RAW"'
98+ 'txt': b'"priority=50" "rp=RAW"'
99 }
100 )
101
102+ def test__parses_txt_binary(self):
103+ input = (
104+ b"=;eth0;IPv4"
105+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
106+ b"_http._tcp;local;"
107+ b"printer.local;"
108+ b"192.168.0.222;"
109+ b"80;"
110+ b'"BluetoothAddress=\xc8i\xcdB\xe2\x09"')
111+ event = parse_avahi_event(input)
112+ self.assertEquals(
113+ b'"BluetoothAddress=\xc8i\xcdB\xe2\x09"', event['txt'])
114+
115 def test__returns_none_for_malformed_input(self):
116- self.assertThat(parse_avahi_event(";;;"), Equals(None))
117+ self.assertThat(parse_avahi_event(b";;;"), Equals(None))
118
119
120 def observe_mdns(*, input, output, verbose=False):
121@@ -143,9 +156,9 @@ class TestObserveMDNS(MAASTestCase):
122 def test__prints_event_json_in_verbose_mode(self):
123 out = io.StringIO()
124 input = (
125- "+;eth0;IPv4"
126- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
127- "_http._tcp;local\n")
128+ b"+;eth0;IPv4"
129+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
130+ b"_http._tcp;local\n")
131 expected_result = {
132 'event': 'BROWSER_NEW',
133 'interface': 'eth0',
134@@ -163,9 +176,9 @@ class TestObserveMDNS(MAASTestCase):
135 def test__skips_unimportant_events_without_verbose_enabled(self):
136 out = io.StringIO()
137 input = (
138- "+;eth0;IPv4"
139- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
140- "_http._tcp;local\n")
141+ b"+;eth0;IPv4"
142+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
143+ b"_http._tcp;local\n")
144 observe_mdns(verbose=False, input=[input], output=out)
145 output = io.StringIO(out.getvalue())
146 lines = output.readlines()
147@@ -174,13 +187,13 @@ class TestObserveMDNS(MAASTestCase):
148 def test__non_verbose_removes_redundant_events_and_outputs_summary(self):
149 out = io.StringIO()
150 input = (
151- "=;eth0;IPv4"
152- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
153- "_http._tcp;local;"
154- "printer.local;"
155- "192.168.0.222;"
156- "80;"
157- '"priority=50" "rp=RAW"\n')
158+ b"=;eth0;IPv4"
159+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
160+ b"_http._tcp;local;"
161+ b"printer.local;"
162+ b"192.168.0.222;"
163+ b"80;"
164+ b'"priority=50" "rp=RAW"\n')
165 observe_mdns(verbose=False, input=[input, input], output=out)
166 output = io.StringIO(out.getvalue())
167 lines = output.readlines()
168@@ -196,13 +209,13 @@ class TestObserveMDNS(MAASTestCase):
169 def test__non_verbose_removes_waits_before_emitting_duplicate_entry(self):
170 out = io.StringIO()
171 input = (
172- "=;eth0;IPv4"
173- ";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
174- "_http._tcp;local;"
175- "printer.local;"
176- "192.168.0.222;"
177- "80;"
178- '"priority=50" "rp=RAW"\n')
179+ b"=;eth0;IPv4"
180+ b";HP\\032Color\\032LaserJet\\032CP2025dn\\032\\040test\\041;"
181+ b"_http._tcp;local;"
182+ b"printer.local;"
183+ b"192.168.0.222;"
184+ b"80;"
185+ b'"priority=50" "rp=RAW"\n')
186 # If we see the same entry 3 times over the course of 15 minutes, we
187 # should only see output two out of the three times.
188 self.patch(time, "monotonic").side_effect = (100.0, 200.0, 900.0)
189@@ -239,7 +252,6 @@ class TestObserveMDNSCommand(MAASTestCase):
190 b"192.168.0.222;"
191 b"80;"
192 b'"priority=50" "rp=RAW"\n')
193- self.test_input_str = self.test_input_bytes.decode('utf-8')
194
195 def test__calls_subprocess_by_default(self):
196 parser = ArgumentParser()
197@@ -263,14 +275,14 @@ class TestObserveMDNSCommand(MAASTestCase):
198 add_arguments(parser)
199 args = parser.parse_args(['--input-file', '-'])
200 output = io.StringIO()
201- run(args, output=output, stdin=[self.test_input_str])
202+ run(args, output=output, stdin=[self.test_input_bytes])
203 self.assertThat(output.getvalue(), Not(HasLength(0)))
204
205 def test__allows_file_input(self):
206- with NamedTemporaryFile('w') as f:
207+ with NamedTemporaryFile('wb') as f:
208 parser = ArgumentParser()
209 add_arguments(parser)
210- f.write(self.test_input_str)
211+ f.write(self.test_input_bytes)
212 f.flush()
213 args = parser.parse_args(['--input-file', f.name])
214 output = io.StringIO()

Subscribers

People subscribed via source and target branches