Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Closes the PR 63 hostile-review blockers: 1. Immutable pinned Python/Pillow runtime (TIMMY_PYTHON absolute, --verify-pin), deployment/runtime re-encode smoke gate in build_release + deploy_staging. 2. Header-only width/height/total-pixel/bomb rejection before full decode; proves 6000x6000 and 12000x12000 stay resource bounded (RSS + address-space caps). 3. Fail-fast decoder concurrency ceiling; tests count actual spawned children. 4. Rate-limiter key cardinality hard-bounded under 20k+ unexpired identities, trusted-loopback-proxy identity, no spoofable forwarded headers, fixed-window boundary burst smoothed by two-window sliding count. 5. build_release explicitly syntax/gates every new JS module + Python re-encoder + production-runtime smoke; CI runs reencode-image test and the runtime pin smoke. 6. Robust polyglot contract via canonical container parsing; rejects uppercase/mixed script, appended HTML, ZIP local/EOCD and archive tails, data after canonical JPEG/PNG/WebP end; no naive compressed-byte scans (false-positive controls pass). 7. Canonical base64 data-URL grammar with byte-exact round trip; rejects missing/excess padding, whitespace/CRLF, malformed and noncanonical encodings; exact MIME policy. 8. Missing Pillow / runtime-unavailable maps to sanitized 503 + manual fallback. 9. Inbound body-read timeout and stop-on-oversize; preserves sanitized 413, base path, provider suppression, and temp cleanup; socket torn down on rejection. Audited prior partial edits: reused the sound source modules, re-wired new tests into the unit/syntax gates, fixed a non-canonical readJson that destroyed the socket before delivering 413/408, and hardened test flakiness (port reuse, EPIPE, startup races).
131 lines
5.6 KiB
Python
131 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate synthetic hostile fixtures for image-ingress security tests.
|
|
|
|
All content is synthetic test data. No real medical images, no external uploads.
|
|
"""
|
|
import os
|
|
|
|
from PIL import Image, PngImagePlugin
|
|
|
|
D = os.path.join(os.path.dirname(__file__), "..", "tests", "fixtures")
|
|
D = os.path.abspath(D)
|
|
os.makedirs(D, exist_ok=True)
|
|
|
|
img = Image.new("RGB", (64, 64))
|
|
for y in range(64):
|
|
for x in range(64):
|
|
img.putpixel((x, y), (x * 4 % 256, y * 4 % 256, 128))
|
|
|
|
# 1. Clean small JPEG (valid control)
|
|
img.save(os.path.join(D, "ingress-clean.jpg"), "JPEG", quality=90)
|
|
|
|
# 2. JPEG with EXIF metadata (Make/Model + GPS IFD pointer)
|
|
ex = img.getexif()
|
|
ex[0x010F] = "HostileCam"
|
|
ex[0x0110] = "Model-X"
|
|
gps_ifd = ex.get_ifd(0x8825)
|
|
gps_ifd[1] = "N" # GPSLatitudeRef
|
|
gps_ifd[2] = (44, 30, 0) # GPSLatitude (degrees, minutes, seconds)
|
|
gps_ifd[4] = (68, 15, 0) # GPSLongitude
|
|
img.save(os.path.join(D, "ingress-exif.jpg"), "JPEG", quality=90, exif=ex)
|
|
|
|
# 3. PNG with tEXt metadata chunks
|
|
png_meta = PngImagePlugin.PngInfo()
|
|
png_meta.add_text("Comment", "sensitive-metadata")
|
|
png_meta.add_text("GPS", "lat:44.0 lon:-68.0")
|
|
img.save(os.path.join(D, "ingress-metadata.png"), "PNG", pnginfo=png_meta)
|
|
|
|
# 4. Spoofed content: HTML masquerading as an image
|
|
with open(os.path.join(D, "ingress-spoofed.html"), "wb") as f:
|
|
f.write(b"<!DOCTYPE html><html><body>not an image</body></html>")
|
|
|
|
# 5. GIF-header polyglot with embedded script payload
|
|
poly = (b"GIF89a\x01\x00\x01\x00\x00\xff\x00,\x00\x00\x00\x00\x01\x00\x01\x00"
|
|
b"\x00\x02\x00;" + b"<script>alert(1)</script>" * 4)
|
|
with open(os.path.join(D, "ingress-polyglot.gif"), "wb") as f:
|
|
f.write(poly)
|
|
|
|
# 6. ZIP-in-JPEG polyglot (GIFAR-style)
|
|
jpg_bytes = open(os.path.join(D, "ingress-clean.jpg"), "rb").read()
|
|
zip_poly = jpg_bytes[:2] + b"PK\x03\x04" + jpg_bytes[2:10] + b"PK\x05\x06" + b"\x00" * 18
|
|
with open(os.path.join(D, "ingress-zip-polyglot.jpg"), "wb") as f:
|
|
f.write(zip_poly)
|
|
|
|
# 7. Truncated JPEG (SOI present, cut before EOI)
|
|
with open(os.path.join(D, "ingress-truncated.jpg"), "wb") as f:
|
|
f.write(jpg_bytes[: len(jpg_bytes) // 2])
|
|
|
|
# 8. Decompression bomb: 12000x12000 sparse PNG, tiny on disk
|
|
bomb = Image.new("L", (12000, 12000), 7)
|
|
bomb.save(os.path.join(D, "ingress-bomb.png"), "PNG", optimize=True)
|
|
print("bomb size:", os.path.getsize(os.path.join(D, "ingress-bomb.png")))
|
|
|
|
# 9. Oversized-dimension JPEG (6000x6000, small on disk)
|
|
big = Image.new("RGB", (6000, 6000), (90, 90, 90))
|
|
big.save(os.path.join(D, "ingress-oversized.jpg"), "JPEG", quality=40)
|
|
print("oversized size:", os.path.getsize(os.path.join(D, "ingress-oversized.jpg")))
|
|
|
|
# 10. Random garbage with jpeg extension
|
|
with open(os.path.join(D, "ingress-garbage.jpg"), "wb") as f:
|
|
f.write(os.urandom(2048))
|
|
|
|
# 11. Empty file
|
|
open(os.path.join(D, "ingress-empty.jpg"), "wb").close()
|
|
|
|
# 12. SVG with embedded script
|
|
with open(os.path.join(D, "ingress-script.svg"), "wb") as f:
|
|
f.write(b'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">'
|
|
b"<rect width='10' height='10'/></svg>")
|
|
|
|
# --- Trailing-data / container polyglots that bypass naive substring scans ---
|
|
# Every one of these is a structurally valid image followed by appended bytes.
|
|
# A canonical parser must reject them on the trailing data itself, not on a
|
|
# signature keyword, so casing and container choice cannot evade the check.
|
|
TRAILERS = {
|
|
"ingress-tail-upper-script.jpg": b"<SCRIPT>alert(1)</SCRIPT>",
|
|
"ingress-tail-mixed-script.jpg": b"<ScRiPt>alert(1)</ScRiPt>",
|
|
"ingress-tail-html.jpg": b"<html><body><img src=x onerror=alert(1)></body></html>",
|
|
"ingress-tail-zip-eocd.jpg": b"PK\x05\x06" + b"\x00" * 18,
|
|
"ingress-tail-zip-local.jpg": b"PK\x03\x04" + b"\x00" * 26,
|
|
"ingress-tail-rar.jpg": b"Rar!\x1a\x07\x00",
|
|
"ingress-tail-7z.jpg": b"7z\xbc\xaf\x27\x1c",
|
|
"ingress-tail-gzip.jpg": b"\x1f\x8b\x08\x00" + b"\x00" * 6,
|
|
"ingress-tail-single-nul.jpg": b"\x00",
|
|
}
|
|
for name, trailer in TRAILERS.items():
|
|
with open(os.path.join(D, name), "wb") as f:
|
|
f.write(jpg_bytes + trailer)
|
|
|
|
# Trailing data appended to a valid PNG (chunk stream ends at IEND).
|
|
png_bytes = open(os.path.join(D, "ingress-metadata.png"), "rb").read()
|
|
with open(os.path.join(D, "ingress-tail-after-iend.png"), "wb") as f:
|
|
f.write(png_bytes + b"<SCRIPT>alert(1)</SCRIPT>")
|
|
|
|
# 13. False-positive control: a legitimate photo-like JPEG whose *compressed*
|
|
# entropy bytes contain archive/script byte sequences by construction. A
|
|
# canonical parser must ACCEPT this; a naive substring scanner rejects it.
|
|
import random
|
|
|
|
random.seed(1337)
|
|
noise = Image.new("RGB", (160, 160))
|
|
for y in range(160):
|
|
for x in range(160):
|
|
noise.putpixel((x, y), (random.randrange(256), random.randrange(256), random.randrange(256)))
|
|
noise.save(os.path.join(D, "ingress-entropy-control.jpg"), "JPEG", quality=95)
|
|
entropy = open(os.path.join(D, "ingress-entropy-control.jpg"), "rb").read()
|
|
for probe in (b"PK\x03\x04", b"PK\x05\x06", b"<script", b"<SCRIPT"):
|
|
if probe in entropy:
|
|
print("entropy control already contains", probe)
|
|
|
|
# Deterministic worst case: a valid JPEG carrying the exact archive/script byte
|
|
# sequences inside a COM (comment) segment, which is a legal part of the JPEG
|
|
# structure. Canonical parsing must accept it; substring scanning must not.
|
|
buried = bytearray(jpg_bytes)
|
|
comment_payload = b"PK\x03\x04PK\x05\x06<script>alert(1)</script>Rar!\x1a\x07\x00"
|
|
com_segment = b"\xff\xfe" + (len(comment_payload) + 2).to_bytes(2, "big") + comment_payload
|
|
buried[2:2] = com_segment # insert immediately after SOI
|
|
with open(os.path.join(D, "ingress-buried-signatures.jpg"), "wb") as f:
|
|
f.write(bytes(buried))
|
|
|
|
print("fixtures written")
|