fix: validate MPF JPEG attachments before redaction

This commit is contained in:
George Byers II committed 2026-09-18 20:35:31 -04:00
1 parent dd69bfd9a6
commit 34bc4b9cd0
4 files changed
+190 -2

No files matched your search

+18
View File
@@ -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
+68 -2
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+104
View File
@@ -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('<HII', RAW, pos + 2)
struct.pack_into('>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('<IIIHH', RAW, pos))
value = uri(raw)
calls = []
monkeypatch.setattr(helpers, '_redact_text', lambda text, **kw: calls.append(text) or text)
assert helpers._is_native_raster_data_uri(value)
assert helpers.redact_session_data(session(value)) == session(value)
assert value not in calls
@pytest.mark.parametrize('attack', ['trailing_secret', 'gap', 'overlap', 'size',
'count', 'table_offset', 'truncated', 'bad_frame',
'first_offset', 'undeclared_image'])
def test_mpf_rejects_unaccounted_or_invalid_bytes(attack):
raw = bytearray(RAW)
if attack == 'trailing_secret':
raw += b'API_KEY=private-trailing-secret'
elif attack == 'undeclared_image':
raw += RAW
elif attack == 'truncated':
del raw[-1:]
elif attack == 'count':
struct.pack_into('<I', raw, TAGS[0xB001] + 8, 999999)
elif attack == 'table_offset':
struct.pack_into('<I', raw, TAGS[0xB002] + 8, len(raw))
elif attack == 'first_offset':
struct.pack_into('<I', raw, ENTRIES + 8, 1)
elif attack == 'bad_frame':
raw[-2:] = b'xx'
else:
pos = ENTRIES + 16 + (4 if attack == 'size' else 8)
value = int.from_bytes(raw[pos:pos + 4], 'little')
struct.pack_into('<I', raw, pos, value + (-1 if attack == 'overlap' else 1))
assert not helpers._is_native_raster_data_uri(uri(raw))
def test_mpf_appended_base64_secret_reaches_real_redactor():
# Pad inside a declared JPEG COM segment, keeping all MP offsets valid,
# so the appended decoded credential aligns to a base64 quantum.
raw = bytearray(RAW)
padding = b'\xff\xfe\x00\x04xx' if len(raw) % 3 == 0 else (
b'\xff\xfe\x00\x02' if len(raw) % 3 == 2 else b'\xff\xfe\x00\x03x')
raw[-2:-2] = padding
last_size = ENTRIES + 16 + 4
struct.pack_into('<I', raw, last_size, int.from_bytes(RAW[last_size:last_size + 4], 'little') + len(padding))
assert len(raw) % 3 == 0
secret = 'AKIA' + 'Z' * 16
value = uri(raw + base64.b64decode(secret))
assert value.endswith(secret)
assert not helpers._is_native_raster_data_uri(value)
result = helpers.redact_session_data(session(value))
assert secret not in result['messages'][0]['content'][0]['image_url']['url']
def test_mpf_outside_authoritative_image_boundary_still_redacts(monkeypatch):
value = uri(RAW)
calls = []
monkeypatch.setattr(helpers, '_redact_text', lambda text, **kw: calls.append(text) or text)
helpers.redact_session_data({'tool_calls': [{'args': session(value)}]})
assert value in calls