diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_report.py | 199 |
1 files changed, 199 insertions, 0 deletions
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() |
