timmy-talking-turd/scripts/reencode_image.py
timmy ccb227921e
Some checks failed
Quality gates / quality (pull_request) Failing after 1m38s
feat: harden image ingress with magic-byte validation, safe re-encode, limits, and rate control
- src/image-ingress.js: sniff magic bytes independent of declared MIME,
  reject polyglots (embedded ZIP/script payloads), enforce 4 MB body cap
  and 4096 px dimension cap before decode, re-encode via fixed-argv
  Pillow subprocess with 15s timeout, strip EXIF/GPS/XMP/tEXt metadata,
  fail closed with sanitized short errors and manual fallback.
- scripts/reencode_image.py: defensive decoder; decompression-bomb
  guarded; prints one JSON verdict line, never image bytes.
- src/rate-limiter.js + server.mjs /api/analyze: bounded fixed-window
  per-client limiter, 429 with retry-after, no payload retention.
- tests/image-ingress.test.js: hostile synthetic fixtures only
  (spoofed MIME, GIF/ZIP polyglots, truncated/garbage/empty images,
  12000x12000 bomb, 6000x6000 oversized, EXIF+GPS, SVG-with-script);
  asserts provider is never reached on rejection and error messages
  contain no bytes/base64/stacks.

Closes #16
2026-08-22 21:43:36 +00:00

89 lines
2.7 KiB
Python

#!/usr/bin/env python3
"""Re-encode one image safely: verify decode, strip metadata, bound dimensions.
Fixed argv contract only:
reencode_image.py --in SOURCE --out TARGET [--max-bytes N] [--max-dimension N]
Reads SOURCE, decodes defensively (decompression-bomb guarded), strips all
metadata by re-encoding to baseline JPEG, and writes TARGET. Prints one JSON
line on success. Never prints image bytes or base64 to stdout/stderr.
"""
import argparse
import io
import json
import os
import sys
Image = None # populated in main() so import errors fail closed
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser()
p.add_argument("--in", dest="source", required=True)
p.add_argument("--out", dest="target", required=True)
p.add_argument("--max-bytes", type=int, default=4 * 1024 * 1024)
p.add_argument("--max-dimension", type=int, default=4096)
return p
def main() -> int:
args = parser().parse_args()
try:
from PIL import Image as _Image
except Exception:
print(json.dumps({"ok": False, "error": "image processing is unavailable"}))
return 3
source_size = os.path.getsize(args.source)
if source_size <= 0 or source_size > args.max_bytes:
print(json.dumps({"ok": False, "error": "rejected"}))
return 2
try:
# Fail closed on decompression bombs before full pixel load.
with _Image.open(args.source) as probe:
try:
probe.load()
except _Image.DecompressionBombError:
print(json.dumps({"ok": False, "error": "dimensions"}))
return 2
width, height = probe.size
if width > args.max_dimension or height > args.max_dimension:
print(json.dumps({"ok": False, "error": "dimensions"}))
return 2
image = probe.convert("RGB")
except Exception:
print(json.dumps({"ok": False, "error": "malformed"}))
return 2
buffer = io.BytesIO()
try:
# Baseline JPEG re-encode drops EXIF/GPS/XMP/tEXt entirely.
image.save(buffer, "JPEG", quality=85, optimize=True, progressive=False)
except Exception:
print(json.dumps({"ok": False, "error": "encode"}))
return 2
data = buffer.getvalue()
if not data or data[:3] != b"\xff\xd8\xff" or len(data) > args.max_bytes:
print(json.dumps({"ok": False, "error": "encode"}))
return 2
with open(args.target, "wb") as handle:
handle.write(data)
print(json.dumps({
"ok": True,
"format": "jpeg",
"width": image.size[0],
"height": image.size[1],
"bytes": len(data),
"metadataStripped": True,
}))
return 0
if __name__ == "__main__":
sys.exit(main())