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 00000000..16b2e30a Binary files /dev/null and b/tests/fixtures/multipicture.jpg differ diff --git a/tests/test_mpf_jpeg_redaction.py b/tests/test_mpf_jpeg_redaction.py new file mode 100644 index 00000000..072df1a5 --- /dev/null +++ b/tests/test_mpf_jpeg_redaction.py @@ -0,0 +1,104 @@ +"""MPF fixture: Pillow MPO, two generated 8x8 red/blue frames. + +Generate with Image.new('RGB', (8, 8), color) for red, blue; +first.save('multipicture.jpg', format='MPO', save_all=True, + append_images=remaining). No private photographs are used. +""" +import base64 +from pathlib import Path +import struct + +import pytest + +from api import helpers + + +RAW = (Path(__file__).parent / 'fixtures' / 'multipicture.jpg').read_bytes() +BASE = RAW.index(b'MPF\0') + 4 +IFD = BASE + int.from_bytes(RAW[BASE + 4:BASE + 8], 'little') +TAGS = {int.from_bytes(RAW[p:p + 2], 'little'): p + for p in range(IFD + 2, IFD + 2 + 12 * int.from_bytes(RAW[IFD:IFD + 2], 'little'), 12)} +ENTRIES = BASE + int.from_bytes(RAW[TAGS[0xB002] + 8:TAGS[0xB002] + 12], 'little') + + +def uri(raw): + return 'data:image/jpeg;base64,' + base64.b64encode(raw).decode('ascii') + + +def session(value): + return {'messages': [{'role': 'user', 'content': [ + {'type': 'image_url', 'image_url': {'url': value}}]}]} + + +@pytest.mark.parametrize('byte_order', ['little', 'big']) +def test_mpf_preserves_native_image_without_text_redaction(monkeypatch, byte_order): + raw = bytearray(RAW) + if byte_order == 'big': + raw[BASE:BASE + 4] = b'MM\x00\x2a' + struct.pack_into('>I', 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('