Some checks failed
Quality gates / quality (pull_request) Failing after 1m38s
- 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
89 lines
2.7 KiB
Python
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())
|