diff options
| -rw-r--r-- | abusectl/report.py | 118 | ||||
| -rw-r--r-- | tests/test_report.py | 199 |
2 files changed, 308 insertions, 9 deletions
diff --git a/abusectl/report.py b/abusectl/report.py index 7410ae2..7cb4a9b 100644 --- a/abusectl/report.py +++ b/abusectl/report.py @@ -44,7 +44,57 @@ _WIDTH = 72 # resource that was never reported. Something must mark the seam, and this # marks it in the only direction that is safe: a fragment ANNOUNCES that it # is a fragment, rather than a whole value having to prove it is whole. +# +# Because the marker is a character a VALUE may also contain, every literal +# backslash in a value is DOUBLED before wrapping and halved on the way +# back. Without that, a value ending in "\" is indistinguishable from a wrap +# marker, and the values here are attacker-supplied: a trailing backslash is +# legal in a URL path, and an attacker reading this source could append one +# to make their indicator garble itself in the report a desk reads. +# +# Doubling EVERY backslash rather than only a trailing one is what keeps the +# encoding unambiguous. Escaping just the last character leaves "x\\" (two +# literal backslashes) encoding to the same text as "x\" followed by a wrap, +# which is the same bug one character further along. _CONTINUATION = "\\" +_ESCAPE = "\\" + + +def _escape(value: str) -> str: + """Double every backslash so none can be read as a continuation marker. + + The inverse is _unescape(). Applied to the value only, never to the + indent or the surrounding prose, so what a reader sees differs from the + literal value in exactly one way and unwrap() undoes exactly that. + """ + return value.replace(_ESCAPE, _ESCAPE + _ESCAPE) + + +def _unescape(value: str) -> str: + """Halve the doubled backslashes _escape() produced. + + Scans left to right, consuming a doubled pair as one character. On text + _escape() actually produced, every run is even and str.replace gives + the same answer, which a mutation test confirmed over every backslash + pattern up to length 11: this is NOT protecting the round trip, and + saying otherwise would overstate what the tests hold down. + + It is kept because unwrap() is public and may be handed a line a user + edited, where a run can be odd. The scan then consumes pairs strictly + left to right and leaves the odd one alone, which is the reading that + matches how the text was written; str.replace rescans its own output + and would fold a stray backslash into the pair beside it. + """ + out = [] + index = 0 + while index < len(value): + if value.startswith(_ESCAPE + _ESCAPE, index): + out.append(_ESCAPE) + index += 2 + else: + out.append(value[index]) + index += 1 + return "".join(out) def _wrap_value(value: str, indent: str) -> list[str]: @@ -59,16 +109,44 @@ def _wrap_value(value: str, indent: str) -> list[str]: An arbitrary break plus an explicit marker is legible precisely because the marker, not the position, carries the meaning. - The value is never altered, only divided; unwrap() is the exact inverse - and there is a test asserting the round trip on a 120-character URL. + The value is ESCAPED first, so its own backslashes cannot be mistaken + for the marker, and the wrap arithmetic then runs over the escaped text: + the column limit governs what is PRINTED, and the escaped form is what + is printed. Measuring the original instead would let a value full of + backslashes overflow the line. + + A break must NEVER land between the two halves of an escaped pair, and + the loop below backs off by one character to guarantee it. This is not + tidiness: unwrap() decides whether a trailing backslash is a marker or + content by the PARITY of the run it ends, and a break inside a pair + splits that run across two lines, so both halves are counted wrongly. + A genuine marker then reads as content, the continuation line is + orphaned, and the tail of the value is silently dropped. + + That defect survived a first fix and a passing test suite, because it + needs a backslash to land exactly on the break column: it appears in + random adversarial values roughly one time in eight and in none of the + hand-written cases. Backing off one character costs a column on a line + that has a backslash at its edge, and buys an invariant that holds for + every input rather than for the inputs someone thought of. + + The value is never altered, only divided and escaped; unwrap() is the + exact inverse, asserted over adversarial values including trailing and + doubled backslashes. """ + value = _escape(value) room = _WIDTH - len(indent) - len(_CONTINUATION) if len(indent) + len(value) <= _WIDTH: return [indent + value] lines = [] while len(value) > room: - lines.append(indent + value[:room] + _CONTINUATION) - value = value[room:] + cut = room + # An odd run of backslashes ending at the cut means the last one is + # the first half of a pair; move the break before it. + if (len(value[:cut]) - len(value[:cut].rstrip(_ESCAPE))) % 2 == 1: + cut -= 1 + lines.append(indent + value[:cut] + _CONTINUATION) + value = value[cut:] lines.append(indent + value) return lines @@ -87,14 +165,36 @@ def unwrap(text: str) -> str: content: no value this module emits begins with a space, because every one of them is an indicator, a header value or an identity, all of which are stripped before they arrive. + + A trailing backslash is a marker only when the run of backslashes it + ends is ODD, because _escape() doubled every literal one. An even run is + entirely escaped content and the line ends there. Testing endswith("\\") + alone was the defect this parity check replaces: it read a value's own + trailing backslash as a marker and swallowed the following line, which + for a Subject meant absorbing the Date header beneath it. + + Rejoining happens BEFORE unescaping, so a doubled pair split across a + break is whole again before it is decoded. """ - out = [] + joined = [] for line in text.splitlines(): - if out and out[-1].endswith(_CONTINUATION): - out[-1] = out[-1][:-len(_CONTINUATION)] + line.lstrip() + if joined and _ends_with_marker(joined[-1]): + joined[-1] = joined[-1][:-len(_CONTINUATION)] + line.lstrip() else: - out.append(line) - return "\n".join(out) + joined.append(line) + return "\n".join(_unescape(line) for line in joined) + + +def _ends_with_marker(line: str) -> bool: + """Whether this line ends in a continuation marker rather than content. + + The marker is one unescaped backslash, and _escape() doubled every + literal one, so the question is purely the PARITY of the trailing run: + odd means the last backslash has no partner and is the marker, even + means every one is half of an escaped pair and the line ends here. + """ + run = len(line) - len(line.rstrip(_ESCAPE)) + return run % 2 == 1 # What each parse.py origin means in a sentence a desk can act on. diff --git a/tests/test_report.py b/tests/test_report.py index 35c68ba..3b13d11 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -723,5 +723,204 @@ class TextPart(unittest.TestCase): self.assertNotIn("Authentication results", text) +class BackslashRoundTrip(unittest.TestCase): + """A value's own backslash must never be read as a wrap marker. + + The continuation marker is a trailing "\\", and a URL path may legally + end in one. Until this was fixed the two were indistinguishable, so an + attacker who read this source could append a backslash and make their + own indicator garble itself in the report an abuse desk reads. That is + an adversarial trigger on attacker-supplied text, not an edge case. + + The property asserted throughout is the only one that closes it: + unwrap(text_part(...)) contains the value EXACTLY, for every value, + wrapped or not. Asserting "the value appears" without unwrap, or + asserting only on long values, both leave the short case open, and the + short case is the one that needs no wrapping to corrupt. + """ + + def _render_ioc(self, value: str) -> str: + manifest = copy.deepcopy(MANIFEST) + manifest["iocs"] = [{"id": "ioc-1", "type": "url", "value": value, + "origin": "body"}] + manifest["contacts"] = [ + {"iocs": ["ioc-1"], "query": "example.invalid", + "abuse": ["abuse@host.invalid"], "source": "rdap"}, + ] + destination = report.email_destinations(manifest["contacts"])[0] + return report.text_part(manifest, destination, IDENTITY) + + def _assert_round_trips(self, value: str) -> None: + text = self._render_ioc(value) + for line in text.splitlines(): + self.assertLessEqual(len(line), 72, line) + self.assertIn(value, report.unwrap(text)) + + def test_a_short_value_ending_in_a_backslash_survives(self): + """The case that needs no wrapping at all to corrupt. + + Nothing is wrapped here, yet unwrap() used to eat the following + line, merging the indicator with its own origin annotation and + rendering "http://a.invalid/xseen in a link in the message body". + One corrupt line where there were two, and a wrong indicator. + """ + self._assert_round_trips("http://a.invalid/x\\") + + def test_a_long_value_ending_in_a_backslash_survives(self): + self._assert_round_trips("http://a.invalid/" + "b" * 90 + "\\") + + def test_a_value_with_an_interior_backslash_survives(self): + self._assert_round_trips("http://a.invalid/x\\y/z") + + def test_a_value_ending_in_two_backslashes_survives(self): + """Whatever escaping is chosen must not have its own off-by-one. + + Doubling every backslash makes a trailing pair into four, and a + decoder that consumes them greedily or in the wrong order gives + back one backslash or three. This is the test that catches that. + """ + self._assert_round_trips("http://a.invalid/x\\\\") + + def test_adversarial_values_round_trip_exactly(self): + """A handful of shapes chosen to sit on the seams. + + The two boundary values matter most: a value that exactly fills a + line and one a single character over it are where an off-by-one in + the wrap arithmetic lives, and a backslash landing exactly on the + break column is where escaping and wrapping interact. + """ + indent = 2 + room = 72 - indent + values = [ + "http://a.invalid/x\\", + "http://a.invalid/x\\y/z", + "http://a.invalid/x\\\\", + "\\" + "a" * 40, + "a" * 40 + "\\", + "http://a.invalid/" + "b" * 90 + "\\", + "a" * room, # exactly fills the line + "a" * (room + 1), # one character over + "a" * (room - 1) + "\\", # backslash at the break + "a" * room + "\\", + "\\\\" + "c" * 80 + "\\\\", + ] + for value in values: + with self.subTest(value=value): + self._assert_round_trips(value) + + def test_a_backslash_landing_on_the_break_column_survives(self): + """The case that a passing suite still missed. + + Escaping doubles each backslash, and a break falling BETWEEN the + two halves of a pair splits the run unwrap() counts the parity of. + Both halves are then misread, a real marker reads as content, the + continuation line is orphaned and the tail of the value is silently + dropped. It needs a backslash at exactly the break column, so no + hand-written case found it; a randomised sweep failed 454 of 3538. + + Walking the backslash across every position around the boundary is + what makes this deterministic rather than luck. + """ + room = 72 - 2 # indent is two spaces for an indicator line + for offset in range(-4, 5): + position = room + offset + if position < 1: + continue + value = "a" * position + "\\" + "b" * 30 + with self.subTest(offset=offset): + self._assert_round_trips(value) + + def test_a_run_of_backslashes_across_the_break_survives(self): + """A run is where an off-by-one in the back-off hides. + + Backing off one character is correct only if the character it lands + on is the first half of a pair; a run of three or four exercises + whether the parity test looks at the run rather than at one + character. + """ + room = 72 - 2 + for length in range(1, 6): + for offset in range(-3, 4): + position = room + offset + if position < 1: + continue + value = "a" * position + "\\" * length + "b" * 20 + with self.subTest(length=length, offset=offset): + self._assert_round_trips(value) + + def test_a_value_that_is_entirely_backslashes_survives(self): + """Escaping doubles the length, so this is the worst case for both + the wrap arithmetic and the parity test at once.""" + for length in (1, 2, 3, 34, 35, 36, 70, 71): + with self.subTest(length=length): + self._assert_round_trips("\\" * length) + + def test_an_attacker_subject_cannot_corrupt_the_header_block(self): + """Subject is attacker-controlled and sits beside headers it can eat. + + This is worse than the URL case: a trailing backslash on Subject + used to swallow the following line, rendering + "Subject: Verify nowDate: Mon, 07 Sep 2026 09:12:40 +0000". The + attacker's own text destroys a DIFFERENT field's value, so the + block misstates what the message declared, which is the one thing + that block exists to report faithfully. + """ + subject = "Verify now\\" + manifest = copy.deepcopy(MANIFEST) + manifest["headers"] = [ + ("Subject", subject), + ("Date", "Mon, 07 Sep 2026 09:12:40 +0000"), + ] + destination = report.email_destinations(manifest["contacts"])[0] + text = report.text_part(manifest, destination, IDENTITY) + + for line in text.splitlines(): + self.assertLessEqual(len(line), 72, line) + joined = report.unwrap(text) + self.assertIn(f"Subject: {subject}", joined) + # The Date must survive intact rather than being absorbed. + self.assertIn("Date: Mon, 07 Sep 2026 09:12:40 +0000", joined) + + def test_a_display_name_ending_in_a_backslash_survives(self): + """The From display name is attacker-controlled too, and a sweep + has already found a spoofed one.""" + value = '"Example Bank\\" <phish@sender.invalid>' + manifest = copy.deepcopy(MANIFEST) + manifest["headers"] = [("From", value), + ("Subject", "Your account requires check")] + destination = report.email_destinations(manifest["contacts"])[0] + text = report.text_part(manifest, destination, IDENTITY) + + joined = report.unwrap(text) + self.assertIn(f"From: {value}", joined) + self.assertIn("Subject: Your account requires check", joined) + + def test_unwrap_reads_a_marker_by_parity_not_by_a_trailing_backslash(self): + """unwrap() is PUBLIC, so its input is not only our own output. + + A case manifest is a file the user edits and a desk may script + against the text part, so unwrap() must decide correctly on a line + it did not generate. Inside generated text the wrap back-off means + a marker always follows an even run, so parity and a plain + endswith() agree and neither is distinguishable by a round-trip + test. They disagree here, on a line ending in an escaped pair and + nothing else: that is content, and the following line must NOT be + absorbed into it. + """ + # "a\\" escaped is a value ending in one literal backslash, whole + # on its line. endswith() reads the second half as a marker. + self.assertEqual(report.unwrap(" a\\\\\n next"), " a\\\n next") + # An odd run IS a marker: two escaped halves plus the marker. + self.assertEqual(report.unwrap(" a\\\\\\\n next"), " a\\next") + + def test_an_identity_containing_a_backslash_survives(self): + identity = {"name": "A Reporter\\", "org": "Example Consulting", + "email": "reporter@example.org"} + destination = report.email_destinations(MANIFEST["contacts"])[0] + text = report.text_part(MANIFEST, destination, identity) + self.assertIn("A Reporter\\", report.unwrap(text)) + self.assertIn("Generated by abusectl.", report.unwrap(text)) + + if __name__ == "__main__": unittest.main() |
