Release: raster-data-URI redaction fast-path (#6311, @inch772) (#6367)

* fix(redaction): skip native raster data URIs

* fix(redaction): accept mixed-case raster MIME types

* fix(redaction): validate complete raster payloads

* docs(changelog): stamp #6311 raster-data-URI redaction fast-path

---------

Co-authored-by: Su Ahn Lee <11433303+inch772@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
authored and GitHub committed 2026-07-20 16:30:04 +00:00
1 parent 4bdc22b7ce
commit 33d55ec897
3 files changed
+782 -2

No files matched your search

+2
View File
@@ -17,6 +17,8 @@
### Fixed ### Fixed
- **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311)
- **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853) - **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853)
- **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291) - **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291)
+418 -2
View File
@@ -1,6 +1,8 @@
""" """
Hermes Web UI -- HTTP helper functions. Hermes Web UI -- HTTP helper functions.
""" """
import base64 as _base64
import binascii as _binascii
import functools import functools
import json as _json import json as _json
import logging import logging
@@ -341,6 +343,13 @@ def _build_redact_fn():
r"""(Authorization:\s*(?:Bearer|Bot)\s+)([^\s'",\]\)]+)""", r"""(Authorization:\s*(?:Bearer|Bot)\s+)([^\s'",\]\)]+)""",
_re.IGNORECASE, _re.IGNORECASE,
) )
# A rejected image data URI can place a syntactically valid AWS key at an
# arbitrary base64 alignment, immediately after another base64 character.
# The general credential regex uses token boundaries to avoid rewriting
# prose identifiers; this defense-in-depth pass ensures the fail-closed
# image path still removes the maintainer's embedded-suffix attack even
# when hermes-agent is unavailable and the local fallback owns redaction.
_EMBEDDED_AWS_ACCESS_KEY_RE = _re.compile(r"AKIA[A-Z0-9]{16}")
_ENV_RE = _re.compile( _ENV_RE = _re.compile(
r"([A-Z0-9_]{0,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})" r"([A-Z0-9_]{0,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})"
r"\s*=\s*(['\"]?)(\S+)\2" r"\s*=\s*(['\"]?)(\S+)\2"
@@ -408,6 +417,7 @@ def _build_redact_fn():
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
text = _CRED_RE.sub(lambda m: _mask(m.group(1)), text) text = _CRED_RE.sub(lambda m: _mask(m.group(1)), text)
text = _EMBEDDED_AWS_ACCESS_KEY_RE.sub(lambda m: _mask(m.group(0)), text)
text = _AUTH_HDR_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), text) text = _AUTH_HDR_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), text)
text = _ENV_RE.sub(_env_replacement, text) text = _ENV_RE.sub(_env_replacement, text)
text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text) text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text)
@@ -576,6 +586,360 @@ def _redact_text(text: str, *, _enabled: bool | None = None) -> str:
return _redact_fn_cached(text) return _redact_fn_cached(text)
_RASTER_IMAGE_DATA_URI_PREFIXES = (
("data:image/png;base64,", "png"),
("data:image/jpeg;base64,", "jpeg"),
("data:image/jpg;base64,", "jpeg"),
("data:image/gif;base64,", "gif"),
("data:image/webp;base64,", "webp"),
("data:image/bmp;base64,", "bmp"),
)
def _is_native_raster_data_uri(text: str) -> bool:
"""Return whether *text* is one complete, canonical raster data URI.
Native image content is opaque binary, not text that the credential regexes
can safely rewrite. The exemption is a credential-boundary decision, so a
matching header or magic prefix is not enough: decode the entire canonical
base64 payload and require the image format to terminate exactly at the end
of the decoded bytes. Any malformed, ambiguous, or trailing content falls
through to normal text redaction.
"""
if not isinstance(text, str):
return False
image_kind = None
payload_start = 0
for prefix, candidate_kind in _RASTER_IMAGE_DATA_URI_PREFIXES:
# URI schemes and MIME type tokens are case-insensitive. Only normalize
# this short header slice — never the multi-megabyte base64 payload.
if text[:len(prefix)].lower() == prefix:
image_kind = candidate_kind
payload_start = len(prefix)
break
if image_kind is None:
return False
payload = text[payload_start:]
if not payload:
return False
try:
raw = _base64.b64decode(payload, validate=True)
except (_binascii.Error, ValueError):
return False
# validate=True rejects foreign characters and misplaced padding; this
# round-trip also rejects non-canonical pad bits and missing/extra padding.
if _base64.b64encode(raw).decode("ascii") != payload:
return False
if image_kind == "png":
return _is_complete_png(raw)
if image_kind == "jpeg":
return _is_complete_jpeg(raw)
if image_kind == "gif":
return _is_complete_gif(raw)
if image_kind == "webp":
return _is_complete_webp(raw)
if image_kind == "bmp":
return _is_complete_bmp(raw)
return False
def _is_complete_png(raw: bytes) -> bool:
if not raw.startswith(b"\x89PNG\r\n\x1a\n"):
return False
pos = 8
chunk_index = 0
saw_idat = False
while pos < len(raw):
if pos + 12 > len(raw):
return False
length = int.from_bytes(raw[pos:pos + 4], "big")
chunk_type = raw[pos + 4:pos + 8]
data_start = pos + 8
data_end = data_start + length
chunk_end = data_end + 4
if chunk_end > len(raw):
return False
if not all((65 <= value <= 90) or (97 <= value <= 122) for value in chunk_type):
return False
if not 65 <= chunk_type[2] <= 90: # PNG reserved bit must be zero.
return False
expected_crc = int.from_bytes(raw[data_end:chunk_end], "big")
actual_crc = _binascii.crc32(chunk_type + raw[data_start:data_end]) & 0xFFFFFFFF
if actual_crc != expected_crc:
return False
if chunk_index == 0:
if chunk_type != b"IHDR" or length != 13:
return False
width = int.from_bytes(raw[data_start:data_start + 4], "big")
height = int.from_bytes(raw[data_start + 4:data_start + 8], "big")
bit_depth = raw[data_start + 8]
color_type = raw[data_start + 9]
valid_depths = {
0: {1, 2, 4, 8, 16},
2: {8, 16},
3: {1, 2, 4, 8},
4: {8, 16},
6: {8, 16},
}
if (
not width
or not height
or bit_depth not in valid_depths.get(color_type, set())
or raw[data_start + 10] != 0
or raw[data_start + 11] != 0
or raw[data_start + 12] not in {0, 1}
):
return False
elif chunk_type == b"IHDR":
return False
if chunk_type == b"IDAT":
saw_idat = True
if chunk_type == b"IEND":
return length == 0 and saw_idat and chunk_end == len(raw)
pos = chunk_end
chunk_index += 1
return False
_JPEG_SOF_MARKERS = {
0xC0, 0xC1, 0xC2, 0xC3,
0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB,
0xCD, 0xCE, 0xCF,
}
def _is_complete_jpeg(raw: bytes) -> bool:
if len(raw) < 4 or raw[:2] != b"\xff\xd8":
return False
pos = 2
saw_sof = False
saw_scan = False
while pos < len(raw):
marker_start = pos
if raw[pos] != 0xFF:
return False
while pos < len(raw) and raw[pos] == 0xFF:
pos += 1
if pos >= len(raw):
return False
marker = raw[pos]
pos += 1
if marker == 0xD9:
return saw_sof and saw_scan and pos == len(raw)
if marker in {0x00, 0x01, 0xD8} or 0xD0 <= marker <= 0xD7:
return False
if pos + 2 > len(raw):
return False
segment_length = int.from_bytes(raw[pos:pos + 2], "big")
if segment_length < 2:
return False
segment_end = pos + segment_length
if segment_end > len(raw):
return False
if marker in _JPEG_SOF_MARKERS:
if segment_length < 8:
return False
saw_sof = True
if marker != 0xDA:
pos = segment_end
continue
saw_scan = True
pos = segment_end
while pos < len(raw):
if raw[pos] != 0xFF:
pos += 1
continue
marker_start = pos
while pos < len(raw) and raw[pos] == 0xFF:
pos += 1
if pos >= len(raw):
return False
scan_marker = raw[pos]
if scan_marker == 0x00 or 0xD0 <= scan_marker <= 0xD7:
pos += 1
continue
pos = marker_start
break
else:
return False
return False
def _gif_subblocks_end(raw: bytes, pos: int) -> int | None:
while pos < len(raw):
size = raw[pos]
pos += 1
if size == 0:
return pos
if pos + size > len(raw):
return None
pos += size
return None
def _is_complete_gif(raw: bytes) -> bool:
if len(raw) < 14 or not raw.startswith((b"GIF87a", b"GIF89a")):
return False
width = int.from_bytes(raw[6:8], "little")
height = int.from_bytes(raw[8:10], "little")
if not width or not height:
return False
packed = raw[10]
pos = 13
if packed & 0x80:
pos += 3 * (1 << ((packed & 0x07) + 1))
if pos > len(raw):
return False
saw_image = False
while pos < len(raw):
introducer = raw[pos]
pos += 1
if introducer == 0x3B:
return saw_image and pos == len(raw)
if introducer == 0x21:
if pos >= len(raw):
return False
pos += 1 # extension label
end = _gif_subblocks_end(raw, pos)
if end is None:
return False
pos = end
continue
if introducer != 0x2C or pos + 9 > len(raw):
return False
image_width = int.from_bytes(raw[pos + 4:pos + 6], "little")
image_height = int.from_bytes(raw[pos + 6:pos + 8], "little")
image_packed = raw[pos + 8]
if not image_width or not image_height:
return False
pos += 9
if image_packed & 0x80:
pos += 3 * (1 << ((image_packed & 0x07) + 1))
if pos >= len(raw):
return False
lzw_minimum_code_size = raw[pos]
if not 2 <= lzw_minimum_code_size <= 11:
return False
pos += 1
end = _gif_subblocks_end(raw, pos)
if end is None:
return False
pos = end
saw_image = True
return False
def _is_webp_image_chunk(chunk_type: bytes, data: bytes) -> bool:
if chunk_type == b"VP8 ":
if len(data) < 10 or data[3:6] != b"\x9d\x01\x2a":
return False
width = int.from_bytes(data[6:8], "little") & 0x3FFF
height = int.from_bytes(data[8:10], "little") & 0x3FFF
return bool(width and height)
if chunk_type == b"VP8L":
# The three high bits of the fifth byte are the version number. The
# current lossless bitstream defines only version zero.
return len(data) >= 5 and data[0] == 0x2F and not data[4] & 0xE0
return False
def _is_complete_webp_frame(data: bytes) -> bool:
"""Validate the nested chunks in one extended-WebP animation frame."""
if len(data) < 16 or data[15] & 0xFC:
return False
pos = 16
saw_image = False
while pos < len(data):
if pos + 8 > len(data):
return False
chunk_type = data[pos:pos + 4]
chunk_size = int.from_bytes(data[pos + 4:pos + 8], "little")
data_start = pos + 8
data_end = data_start + chunk_size
chunk_end = data_end + (chunk_size & 1)
if chunk_end > len(data):
return False
chunk_data = data[data_start:data_end]
if chunk_type in {b"VP8 ", b"VP8L"}:
if saw_image or not _is_webp_image_chunk(chunk_type, chunk_data):
return False
saw_image = True
elif chunk_type != b"ALPH":
return False
pos = chunk_end
return saw_image and pos == len(data)
def _is_complete_webp(raw: bytes) -> bool:
if (
len(raw) < 20
or raw[:4] != b"RIFF"
or raw[8:12] != b"WEBP"
or int.from_bytes(raw[4:8], "little") + 8 != len(raw)
):
return False
pos = 12
saw_image = False
while pos < len(raw):
if pos + 8 > len(raw):
return False
chunk_type = raw[pos:pos + 4]
chunk_size = int.from_bytes(raw[pos + 4:pos + 8], "little")
data_start = pos + 8
data_end = data_start + chunk_size
chunk_end = data_end + (chunk_size & 1)
if chunk_end > len(raw):
return False
data = raw[data_start:data_end]
if chunk_type in {b"VP8 ", b"VP8L"}:
if saw_image or not _is_webp_image_chunk(chunk_type, data):
return False
saw_image = True
elif chunk_type == b"VP8X":
if len(data) != 10 or data[0] & 0x81 or any(data[1:4]):
return False
elif chunk_type == b"ANMF":
if not _is_complete_webp_frame(data):
return False
saw_image = True
pos = chunk_end
return saw_image and pos == len(raw)
def _is_complete_bmp(raw: bytes) -> bool:
if len(raw) < 26 or raw[:2] != b"BM":
return False
if int.from_bytes(raw[2:6], "little") != len(raw):
return False
pixel_offset = int.from_bytes(raw[10:14], "little")
dib_size = int.from_bytes(raw[14:18], "little")
if dib_size == 12:
width = int.from_bytes(raw[18:20], "little")
height = int.from_bytes(raw[20:22], "little")
planes = int.from_bytes(raw[22:24], "little")
bits_per_pixel = int.from_bytes(raw[24:26], "little")
elif dib_size >= 40 and 14 + dib_size <= len(raw):
width = int.from_bytes(raw[18:22], "little", signed=True)
height = int.from_bytes(raw[22:26], "little", signed=True)
planes = int.from_bytes(raw[26:28], "little")
bits_per_pixel = int.from_bytes(raw[28:30], "little")
else:
return False
return (
bool(width)
and bool(height)
and planes == 1
and bits_per_pixel in {1, 2, 4, 8, 16, 24, 32}
and 14 + dib_size <= pixel_offset < len(raw)
)
def _redact_value(v, *, _enabled: bool | None = None): def _redact_value(v, *, _enabled: bool | None = None):
"""Recursively redact credentials from strings, dicts, and lists. """Recursively redact credentials from strings, dicts, and lists.
@@ -585,12 +949,64 @@ def _redact_value(v, *, _enabled: bool | None = None):
if isinstance(v, str): if isinstance(v, str):
return _redact_text(v, _enabled=_enabled) return _redact_text(v, _enabled=_enabled)
if isinstance(v, dict): if isinstance(v, dict):
return {k: _redact_value(val, _enabled=_enabled) for k, val in v.items()} return {
key: _redact_value(value, _enabled=_enabled)
for key, value in v.items()
}
if isinstance(v, list): if isinstance(v, list):
return [_redact_value(item, _enabled=_enabled) for item in v] return [_redact_value(item, _enabled=_enabled) for item in v]
return v return v
def _redact_message_content_part(part, *, _enabled: bool):
"""Redact one canonical ``messages[*].content[*]`` part.
The raster exemption exists only at this authoritative schema position.
Image-shaped dictionaries in metadata, tools, todos, journals, or arbitrary
nested values remain on the normal fail-closed redaction path.
"""
if not _enabled or not (
isinstance(part, dict)
and part.get("type") == "image_url"
and isinstance(part.get("image_url"), dict)
):
return _redact_value(part, _enabled=_enabled)
result = {}
for key, value in part.items():
if key != "image_url":
result[key] = _redact_value(value, _enabled=_enabled)
continue
result[key] = {
image_key: image_value
if image_key == "url" and _is_native_raster_data_uri(image_value)
else _redact_value(image_value, _enabled=_enabled)
for image_key, image_value in value.items()
}
return result
def _redact_messages(messages, *, _enabled: bool):
if not isinstance(messages, list):
return _redact_value(messages, _enabled=_enabled)
redacted = []
for message in messages:
if not isinstance(message, dict):
redacted.append(_redact_value(message, _enabled=_enabled))
continue
item = {}
allow_native_image = message.get("role") == "user"
for key, value in message.items():
if allow_native_image and key == "content" and isinstance(value, list):
item[key] = [
_redact_message_content_part(part, _enabled=_enabled)
for part in value
]
else:
item[key] = _redact_value(value, _enabled=_enabled)
redacted.append(item)
return redacted
def redact_session_data(session_dict: dict) -> dict: def redact_session_data(session_dict: dict) -> dict:
"""Redact credentials from message content, tool data, and session sidecars. """Redact credentials from message content, tool data, and session sidecars.
@@ -610,7 +1026,7 @@ def redact_session_data(session_dict: dict) -> dict:
if isinstance(result.get('title'), str): if isinstance(result.get('title'), str):
result['title'] = _redact_text(result['title'], _enabled=_enabled) result['title'] = _redact_text(result['title'], _enabled=_enabled)
if 'messages' in result: if 'messages' in result:
result['messages'] = _redact_value(result['messages'], _enabled=_enabled) result['messages'] = _redact_messages(result['messages'], _enabled=_enabled)
if 'tool_calls' in result: if 'tool_calls' in result:
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled) result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
if 'todo_state' in result: if 'todo_state' in result:
+362
View File
@@ -0,0 +1,362 @@
"""Regression coverage for large native image redaction overhead.
Native multimodal messages store raster images as base64 data URIs. Those
opaque image bytes must not be sent through the text credential redactor: a
large image can randomly contain one of the cheap prefilter's markers and then
pay for every regex pass in the agent redactor.
"""
import base64
import json
import struct
import zlib
import pytest
from api import helpers
from api.session_export_html import render_session_html
_FAKE_AWS_KEY = "AKIATESTFAKEKEY12345"
_ONE_PIXEL_JPEG = base64.b64decode(
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k="
)
_ONE_PIXEL_GIF = base64.b64decode(
"R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
)
_ONE_PIXEL_WEBP = base64.b64decode(
"UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuU"
)
def _one_pixel_bmp() -> bytes:
pixel_data = b"\x00\x00\x00\x00" # BGR black + row padding
file_size = 54 + len(pixel_data)
file_header = b"BM" + struct.pack("<IHHI", file_size, 0, 0, 54)
dib_header = struct.pack(
"<IiiHHIIiiII",
40,
1,
1,
1,
24,
0,
len(pixel_data),
2835,
2835,
0,
0,
)
return file_header + dib_header + pixel_data
def _png_chunk(kind: bytes, data: bytes) -> bytes:
crc = zlib.crc32(kind + data) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc)
def _valid_png_bytes_with_sensitive_marker(*, extra_bytes: int = 0) -> bytes:
"""Build a real 1x1 RGBA PNG whose base64 contains an ``AKIA`` quartet."""
marker_bytes = base64.b64decode("AKIA")
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0)
# The first data byte starts at absolute offset 41. One byte of padding
# aligns marker_bytes to a base64 quantum, preserving the literal AKIA
# quartet that exercises the redaction prefilter.
marker_chunk = _png_chunk(
b"raNd",
b"\x00" + marker_bytes + (b"image-bytes" * 32) + (b"x" * extra_bytes),
)
idat = _png_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00\x00"))
prefix = b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", ihdr) + marker_chunk + idat
# Make the complete PNG length a multiple of three. This lets the
# maintainer-supplied post-IEND attack append decoded bytes whose standard
# base64 representation remains the literal fake credential.
for pad_len in range(3):
raw = prefix + _png_chunk(b"paDd", b"x" * pad_len) + _png_chunk(b"IEND", b"")
if len(raw) % 3 == 0:
encoded = base64.b64encode(raw).decode("ascii")
assert "AKIA" in encoded
return raw
raise AssertionError("could not align synthetic PNG")
def _png_data_uri_with_sensitive_marker() -> str:
raw = _valid_png_bytes_with_sensitive_marker()
encoded = base64.b64encode(raw).decode("ascii")
assert "AKIA" in encoded
return f"data:image/png;base64,{encoded}"
def _png_data_uri_with_post_iend_credential() -> str:
raw = _valid_png_bytes_with_sensitive_marker()
suffix = base64.b64decode(_FAKE_AWS_KEY, validate=True)
encoded = base64.b64encode(raw + suffix).decode("ascii")
assert encoded.endswith(_FAKE_AWS_KEY)
return f"data:image/png;base64,{encoded}"
def test_native_raster_data_uri_bypasses_text_redactor(monkeypatch):
uri = _png_data_uri_with_sensitive_marker()
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or "unexpected-redaction",
)
content_part = {
"type": "image_url",
"image_url": {"url": uri, "detail": "auto"},
}
redacted = helpers.redact_session_data(
{"messages": [{"role": "user", "content": [content_part]}]}
)
assert redacted["messages"][0]["content"][0] == content_part
assert calls == []
def test_multimegabyte_native_raster_still_bypasses_text_redactor(monkeypatch):
raw = _valid_png_bytes_with_sensitive_marker(extra_bytes=1_000_000)
uri = f"data:image/png;base64,{base64.b64encode(raw).decode('ascii')}"
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or "unexpected-redaction",
)
result = helpers.redact_session_data(
{
"messages": [
{
"role": "user",
"content": [{"type": "image_url", "image_url": {"url": uri}}],
}
]
}
)
assert result["messages"][0]["content"][0]["image_url"]["url"] == uri
assert calls == []
def test_native_raster_data_uri_accepts_uppercase_mime(monkeypatch):
uri = _png_data_uri_with_sensitive_marker().replace(
"data:image/png",
"data:image/PNG",
1,
)
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or "unexpected-redaction",
)
content_part = {"type": "image_url", "image_url": {"url": uri}}
redacted = helpers.redact_session_data(
{"messages": [{"role": "user", "content": [content_part]}]}
)
assert redacted["messages"][0]["content"][0] == content_part
assert calls == []
def test_raster_data_uri_outside_image_part_keeps_security_boundary(monkeypatch):
uri = _png_data_uri_with_sensitive_marker()
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or "redacted",
)
assert helpers._redact_value({"content": uri}, _enabled=True) == {
"content": "redacted",
}
assert calls == [uri]
def test_non_raster_image_part_keeps_security_boundary(monkeypatch):
uri = "data:image/svg+xml;base64," + base64.b64encode(
b"<svg> " + base64.b64decode("AKIA") + b" sensitive text</svg>"
).decode("ascii")
assert "AKIA" in uri
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or "redacted",
)
result = helpers._redact_value(
{"type": "image_url", "image_url": {"url": uri}},
_enabled=True,
)
assert result["image_url"]["url"] == "redacted"
assert calls == [uri]
def test_declared_raster_with_wrong_magic_keeps_security_boundary(monkeypatch):
encoded = base64.b64encode(b"not-a-png" + base64.b64decode("AKIA")).decode("ascii")
uri = f"data:image/png;base64,{encoded}"
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or "redacted",
)
result = helpers._redact_value(
{"type": "image_url", "image_url": {"url": uri}},
_enabled=True,
)
assert result["image_url"]["url"] == "redacted"
assert calls == [uri]
def test_post_iend_credential_falls_through_to_text_redaction(monkeypatch):
uri = _png_data_uri_with_post_iend_credential()
calls = []
def redact(text):
calls.append(text)
return text.replace(_FAKE_AWS_KEY, "[REDACTED]")
monkeypatch.setattr(helpers, "_redact_fn_cached", redact)
content_part = {"type": "image_url", "image_url": {"url": uri}}
result = helpers.redact_session_data(
{"messages": [{"role": "user", "content": [content_part]}]}
)
assert _FAKE_AWS_KEY not in result["messages"][0]["content"][0]["image_url"]["url"]
assert calls == [uri]
assert helpers._is_native_raster_data_uri(uri) is False
def test_post_iend_credential_is_removed_by_real_redactor():
uri = _png_data_uri_with_post_iend_credential()
result = helpers.redact_session_data(
{
"messages": [
{
"role": "user",
"content": [{"type": "image_url", "image_url": {"url": uri}}],
}
]
}
)
assert _FAKE_AWS_KEY not in json.dumps(result)
def test_image_exemption_is_scoped_to_direct_message_content_parts(monkeypatch):
valid_uri = _png_data_uri_with_sensitive_marker()
calls = []
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: calls.append(text) or text.replace("AKIA", "[REDACTED]"),
)
image_shaped = {"type": "image_url", "image_url": {"url": valid_uri}}
session = {
"messages": [
{
"role": "user",
"content": [
image_shaped,
{"type": "text", "text": f"sibling={_FAKE_AWS_KEY}"},
],
"metadata": image_shaped,
},
{"role": "assistant", "content": [image_shaped]},
],
"tool_calls": [image_shaped],
"todo_state": {"image": image_shaped},
"runtime_journal_snapshot": {"messages": [image_shaped]},
}
result = helpers.redact_session_data(session)
# Only the canonical messages[*].content[*] image URL stays opaque.
assert result["messages"][0]["content"][0]["image_url"]["url"] == valid_uri
assert _FAKE_AWS_KEY not in result["messages"][0]["content"][1]["text"]
assert "AKIA" not in result["messages"][0]["metadata"]["image_url"]["url"]
assert "AKIA" not in result["messages"][1]["content"][0]["image_url"]["url"]
assert "AKIA" not in result["tool_calls"][0]["image_url"]["url"]
assert "AKIA" not in result["todo_state"]["image"]["image_url"]["url"]
assert "AKIA" not in result["runtime_journal_snapshot"]["messages"][0]["image_url"]["url"]
assert calls
def test_crafted_image_is_redacted_from_json_html_and_journal(monkeypatch):
uri = _png_data_uri_with_post_iend_credential()
monkeypatch.setattr(
helpers,
"_redact_fn_cached",
lambda text: text.replace(_FAKE_AWS_KEY, "[REDACTED]"),
)
crafted_part = {"type": "image_url", "image_url": {"url": uri}}
safe = helpers.redact_session_data(
{
"title": "security regression",
"messages": [{"role": "user", "content": [crafted_part]}],
"runtime_journal_snapshot": {
"messages": [crafted_part],
"persisted_payload": uri,
},
}
)
json_export = json.dumps(safe)
html_export = render_session_html(safe)
assert _FAKE_AWS_KEY not in json_export
assert _FAKE_AWS_KEY not in html_export
assert _FAKE_AWS_KEY not in json.dumps(safe["runtime_journal_snapshot"])
def test_uncertain_base64_forms_fail_closed():
valid_uri = _png_data_uri_with_sensitive_marker()
header, payload = valid_uri.split(",", 1)
hostile = [
f"{header},{payload[:16]}\n{payload[16:]}",
f"{valid_uri}?token={_FAKE_AWS_KEY}",
f"{valid_uri} {_FAKE_AWS_KEY}",
f"{header},{payload[:-1]}",
f"{header},{payload}=A",
]
assert all(not helpers._is_native_raster_data_uri(value) for value in hostile)
@pytest.mark.parametrize(
"mime,raw",
[
("png", _valid_png_bytes_with_sensitive_marker()),
("jpeg", _ONE_PIXEL_JPEG),
("gif", _ONE_PIXEL_GIF),
("webp", _ONE_PIXEL_WEBP),
("bmp", _one_pixel_bmp()),
],
)
def test_supported_raster_requires_exact_terminal_boundary(mime, raw):
exact = f"data:image/{mime};base64,{base64.b64encode(raw).decode('ascii')}"
trailing = (
f"data:image/{mime};base64,"
f"{base64.b64encode(raw + b'trailing-bytes').decode('ascii')}"
)
assert helpers._is_native_raster_data_uri(exact) is True
assert helpers._is_native_raster_data_uri(trailing) is False
def test_webp_animation_frame_requires_real_nested_image_chunk():
fake_frame = b"\x00" * 16
anmf = b"ANMF" + struct.pack("<I", len(fake_frame)) + fake_frame
raw = b"RIFF" + struct.pack("<I", len(anmf) + 4) + b"WEBP" + anmf
uri = f"data:image/webp;base64,{base64.b64encode(raw).decode('ascii')}"
assert helpers._is_native_raster_data_uri(uri) is False