fix: validate MPF JPEG attachments before redaction
This commit is contained in:
1 parent
dd69bfd9a6
commit
34bc4b9cd0
4 files changed
+190
-2
No files matched your search
+18
@@ -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
@@ -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
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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
|
||||
Reference in new issue
Block a user