From 34bc4b9cd0b6dc51c2f33ca34d13bbc48a78e6c9 Mon Sep 17 00:00:00 2001 From: George Byers II Date: Fri, 18 Sep 2026 20:35:27 -0400 Subject: [PATCH] fix: validate MPF JPEG attachments before redaction --- TESTING.md | 18 ++++++ api/helpers.py | 70 ++++++++++++++++++++- tests/fixtures/multipicture.jpg | Bin 0 -> 1372 bytes tests/test_mpf_jpeg_redaction.py | 104 +++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/multipicture.jpg create mode 100644 tests/test_mpf_jpeg_redaction.py diff --git a/TESTING.md b/TESTING.md index 6b2f2862..af111974 100644 --- a/TESTING.md +++ b/TESTING.md @@ -36,6 +36,24 @@ npm run lint:runtime npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js" ``` +## Native raster redaction boundary + +Run `./scripts/test.sh tests/test_mpf_jpeg_redaction.py tests/test_raster_data_uri_redaction.py tests/test_security_redaction.py`. +Native `messages[*].content[*].image_url.url` raster data may bypass text +credential scanning only after complete container validation. MPF JPEGs require +an APP2 MP Index with bounded TIFF entries, contiguous declared JPEG extents, +and a complete SOF/scan/EOI sequence in each image. Gaps, overlaps, malformed +frames, and trailing bytes fall back to text redaction. Image-shaped tool +metadata does not acquire this exemption. This is structural validation, not +pixel decoding or secret detection inside image metadata/pixels. + +`tests/fixtures/multipicture.jpg` contains two Pillow-generated 8x8 solid-color +frames (red and blue), not private photographs; generation instructions live in +`tests/test_mpf_jpeg_redaction.py`. The tests cover both TIFF byte orders and +credential text appended after the final EOI. Helper timing improvements do +not by themselves prove browser or shared-server responsiveness; verify those +separately with a real conversation and concurrent requests. + ## Python lint gate (ruff) — forward-looking, new-code-only The Python twin of the ESLint runtime guard. A curated `ruff` ruleset diff --git a/api/helpers.py b/api/helpers.py index 9a8e7f36..71164cce 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -724,12 +724,59 @@ _JPEG_SOF_MARKERS = { } -def _is_complete_jpeg(raw: bytes) -> bool: +def _mpf_image_ranges(data: bytes, base: int) -> list[tuple[int, int]] | None: + """Read an MP Index TIFF inside APP2; offsets are relative to its header. + + Only the declared JPEG extents are trusted, not a decoder's willingness to + ignore trailing bytes. The caller checks contiguous coverage and each JPEG. + """ + if len(data) < 8 or data[:4] not in {b"II\x2a\x00", b"MM\x00\x2a"}: + return None + order = "little" if data[:2] == b"II" else "big" + + def uint(pos: int, size: int = 4) -> int: + return int.from_bytes(data[pos:pos + size], order) + + ifd = uint(4) + if ifd < 8 or ifd + 2 > len(data): + return None + count = uint(ifd, 2) + table_end = ifd + 2 + 12 * count + if table_end + 4 > len(data): + return None + tags = {} + for pos in range(ifd + 2, table_end, 12): + tag = uint(pos, 2) + if tag in tags: + return None + tags[tag] = (uint(pos + 2, 2), uint(pos + 4), uint(pos + 8)) + if tags.get(0xB001, ())[:2] != (4, 1): + return None + images = tags[0xB001][2] + entry = tags.get(0xB002) + if not entry or images < 2 or entry[:2] != (7, 16 * images): + return None + offset = entry[2] + if offset < table_end + 4 or offset + 16 * images > len(data): + return None + ranges = [] + for index in range(images): + pos = offset + index * 16 + attributes, size, relative = uint(pos), uint(pos + 4), uint(pos + 8) + if attributes & 0x07000000 or size < 4 or (index == 0 and relative != 0): + return None + start = 0 if index == 0 else base + relative + ranges.append((start, start + size)) + return ranges + + +def _is_complete_jpeg(raw: bytes, *, _allow_mpf: bool = True) -> bool: if len(raw) < 4 or raw[:2] != b"\xff\xd8": return False pos = 2 saw_sof = False saw_scan = False + mpf_ranges = None while pos < len(raw): marker_start = pos if raw[pos] != 0xFF: @@ -741,7 +788,20 @@ def _is_complete_jpeg(raw: bytes) -> bool: marker = raw[pos] pos += 1 if marker == 0xD9: - return saw_sof and saw_scan and pos == len(raw) + if not saw_sof or not saw_scan: + return False + if mpf_ranges is None: + return pos == len(raw) + if mpf_ranges[0] != (0, pos): + return False + # No gaps, overlaps, undeclared pictures or post-EOI payloads. + for start, end in mpf_ranges[1:]: + if start != pos or end > len(raw): + return False + if not _is_complete_jpeg(raw[start:end], _allow_mpf=False): + return False + pos = end + return pos == len(raw) if marker in {0x00, 0x01, 0xD8} or 0xD0 <= marker <= 0xD7: return False if pos + 2 > len(raw): @@ -752,6 +812,12 @@ def _is_complete_jpeg(raw: bytes) -> bool: segment_end = pos + segment_length if segment_end > len(raw): return False + if _allow_mpf and marker == 0xE2 and raw[pos + 2:pos + 6] == b"MPF\x00": + if mpf_ranges is not None: + return False + mpf_ranges = _mpf_image_ranges(raw[pos + 6:segment_end], pos + 6) + if mpf_ranges is None: + return False if marker in _JPEG_SOF_MARKERS: if segment_length < 8: return False diff --git a/tests/fixtures/multipicture.jpg b/tests/fixtures/multipicture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..16b2e30aba86f3a9bac57e302ed67e723197a310 GIT binary patch literal 1372 zcmex=wh=DOELf4NWZ*Q!{f5ODks=S2uSLPp{yR(6I1`$f)F$)U@=B z%&g*)(z5c3%Btp;*0%PJ&aO$5r%atTea6gLixw|gx@`H1m8&*w-m-Pu_8mKS9XfpE z=&|D`PM*4S`O4L6*Kgds_3+W-Cr_U}fAR9w$4{TXeEs(Q$Io9Ne=#yJL%anfAwEO% zmmttzOe`$SEbJhEF*20{F|!~GtD+&BkYgZwVxh2-Q6qGeQ$1KQT&+zEQqOPBlzYG7r`5#*nA5D0p U36C}j@8cI', raw, BASE + 4, IFD - BASE) + struct.pack_into('>H', raw, IFD, len(TAGS)) + for tag, pos in TAGS.items(): + kind, count, value = struct.unpack_from('HHI', raw, pos, tag, kind, count) + if tag != 0xB000: # Version is four raw ASCII bytes, not an integer. + struct.pack_into('>I', raw, pos + 8, value) + for pos in range(ENTRIES, ENTRIES + 32, 16): + struct.pack_into('>IIIHH', raw, pos, *struct.unpack_from('