Files
remove-ai-watermarks/tests/test_security_clamp.py
T
Victor KuznetsovandClaude Opus 5 2668f1302d Read WebP metadata past the scan window and surface C2PA reader failures
Three gaps found while measuring the record path against the file path, each one
a signal the library could not see:

WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed
read stops short of the label. `_riff_late_metadata` steps over the coded image to
reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus
files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there.
The decoder-backed fallback now covers only what it is actually for -- metadata the
raw bytes do not spell, such as a compressed PNG `zTXt` packet.

A C2PA reader failure returned the same `None` as a file with no manifest, so a
verdict could fall back to the raw byte scan with no trace anywhere. Failures now
log at warning and only genuine ones do: a file without credentials never reaches
that branch, and an unsupported container is demoted to debug through the reader's
own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG.

`scan_dataset.py` never registered the pillow-heif opener it declares as a
dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer
that was 397 of 406 features NaN instead of 136.

`_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping
each chunk to the bytes remaining is not enough on its own: one chunk can declare a
length spanning most of the file, and this runs on the memoized verdict path over
images from arbitrary sources.

Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the
one-call entry point and the versioned JSON contract for the record path.

Record-vs-file equality holds over 3,478 corpus images, and the eight files these
fixes recovered still report AI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00

160 lines
5.9 KiB
Python

"""Regression guards for malformed-length DoS and removal-truncation bugs.
Three verified bugs are locked in here:
1. PNG C2PA parsers (``c2pa.has_c2pa_metadata`` / ``extract_c2pa_info`` and
``metadata._png_late_metadata`` via ``scan_head``) used the raw 32-bit chunk
``length`` field directly in ``f.read(length)``. A crafted file can declare
``length = 0x7FFFFFFF`` (~2 GiB) on a 60-byte file, forcing a multi-GB
allocation. The fix clamps ``length`` to the bytes actually remaining.
2. ISOBMFF ``strip_c2pa_boxes`` truncated the file from a malformed box to EOF
(the box walker returns early), so ``remove_ai_metadata`` could emit a
shorter file and report success. The fix returns the input unchanged when the
walk does not reach EOF.
"""
from __future__ import annotations
import struct
import tracemalloc
from typing import TYPE_CHECKING
from remove_ai_watermarks import metadata
from remove_ai_watermarks._internal import c2pa, isobmff
if TYPE_CHECKING:
from pathlib import Path
PNG_SIG = b"\x89PNG\r\n\x1a\n"
_HUGE = 0x7FFFFFFF # ~2 GiB declared length on a tiny file
def _png_with_huge_c2pa_chunk() -> bytes:
"""A ~60-byte 'PNG' whose caBX chunk header lies about its length."""
header = struct.pack(">I", _HUGE) + c2pa.C2PA_CHUNK_TYPE
body = b"jumbc2pa-not-really" # far shorter than the declared length
return PNG_SIG + header + body
class TestPngLengthClampNoAlloc:
"""Clamping makes the parsers read only the real bytes, not the lie."""
def test_has_c2pa_metadata_is_bounded(self, tmp_path):
path = tmp_path / "evil.png"
path.write_bytes(_png_with_huge_c2pa_chunk())
tracemalloc.start()
try:
# Must return quickly without allocating gigabytes and without raising.
c2pa.has_c2pa_metadata(path)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
assert peak < 50 * 1024 * 1024 # < 50 MB locks in the clamp
def test_extract_c2pa_info_is_bounded(self, tmp_path):
path = tmp_path / "evil.png"
path.write_bytes(_png_with_huge_c2pa_chunk())
tracemalloc.start()
try:
c2pa.extract_c2pa_info(path)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
assert peak < 50 * 1024 * 1024
def test_extract_c2pa_chunk_is_bounded(self, tmp_path):
path = tmp_path / "evil.png"
path.write_bytes(_png_with_huge_c2pa_chunk())
tracemalloc.start()
try:
c2pa.extract_c2pa_chunk(path)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
assert peak < 50 * 1024 * 1024
def test_png_late_metadata_scan_is_bounded(self, tmp_path):
# A PNG with a real IDAT pushing the late-scan window past 1 MB, then a
# tEXt chunk lying about its length. scan_head() -> _png_late_metadata().
idat = b"\x00" * (1024 * 1024 + 16)
text_header = struct.pack(">I", _HUGE) + b"tEXt"
blob = (
PNG_SIG
+ struct.pack(">I", len(idat))
+ b"IDAT"
+ idat
+ b"\x00\x00\x00\x00" # fake CRC
+ text_header
+ b"AIGC short"
)
path = tmp_path / "evil_late.png"
path.write_bytes(blob)
tracemalloc.start()
try:
metadata.scan_head(path)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
# head itself is ~1 MB; the clamp keeps the late read tiny. Generous cap.
assert peak < 50 * 1024 * 1024
def _box(box_type: bytes, payload: bytes) -> bytes:
return struct.pack(">I", 8 + len(payload)) + box_type + payload
class TestIsobmffStripFailSafe:
def test_well_formed_file_still_strips_uuid(self):
ftyp = _box(b"ftyp", b"isom\x00\x00\x00\x00mp42")
c2pa_box = _box(b"uuid", isobmff.C2PA_UUID + b"manifest-bytes")
mdat = _box(b"mdat", b"\x00" * 32)
data = ftyp + c2pa_box + mdat
cleaned, stripped = isobmff.strip_c2pa_boxes(data)
assert stripped == 1
assert len(cleaned) == len(data) - len(c2pa_box)
assert isobmff.C2PA_UUID not in cleaned
def test_malformed_box_does_not_truncate_tail(self):
ftyp = _box(b"ftyp", b"isom\x00\x00\x00\x00mp42")
c2pa_box = _box(b"uuid", isobmff.C2PA_UUID + b"manifest-bytes")
# A box claiming ~2 GiB before EOF: the walker returns early here.
bad_box = struct.pack(">I", _HUGE) + b"free" + b"\x00" * 16
data = ftyp + c2pa_box + bad_box
cleaned, stripped = isobmff.strip_c2pa_boxes(data)
# Fail-safe: input returned unchanged, nothing stripped, no truncation.
assert stripped == 0
assert cleaned == data
assert len(cleaned) == len(data)
class TestRiffLateMetadataIsBounded:
"""A metadata scan must not become a near-full-file read because one chunk lied
about its length. This runs on the memoized verdict path, over images from
arbitrary sources."""
def _webp_with_declared_length(self, path: Path, declared: int, payload: bytes) -> Path:
chunk = b"XMP " + declared.to_bytes(4, "little") + payload
body = b"WEBP" + b"VP8 " + (4).to_bytes(4, "little") + b"\x00\x00\x00\x00" + chunk
path.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body)
return path
def test_a_chunk_claiming_the_whole_file_is_clamped_to_what_remains(self, tmp_path: Path):
target = self._webp_with_declared_length(tmp_path / "liar.webp", 1 << 30, b"AI" * 64)
collected = metadata._riff_late_metadata(target, 12)
assert collected == b"AI" * 64
def test_the_total_is_capped(self, tmp_path: Path):
payload = b"x" * 4096
target = self._webp_with_declared_length(tmp_path / "big.webp", len(payload), payload)
assert len(metadata._riff_late_metadata(target, 12, max_total=512)) == 512