Harden image ingress: pinned runtime, header-bomb rejection, concurrency ceiling, bounded rate limiter, canonical data-URL/polyglot contract, 503-on-unavailable, body-read timeout
Quality gates / quality (pull_request) Failing after 2m39s
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).
This commit is contained in:
1 parent
ccb227921e
commit
517c8dbac3
35 files changed
+1844
-105
No files matched your search
@@ -133,11 +133,16 @@ def main() -> int:
|
||||
contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg"
|
||||
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree)
|
||||
run(["npm", "audit", "--audit-level=high"], tree)
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "tests/staging.acceptance.mjs"):
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/image-ingress.js", "src/rate-limiter.js", "src/client-identity.js", "src/image-container.js", "tests/staging.acceptance.mjs", "tests/image-polyglot.test.js", "tests/image-dataurl.test.js", "tests/image-concurrency.test.js", "tests/image-unavailable.test.js", "tests/rate-identity.test.js", "tests/body-timeout.test.js"):
|
||||
run(["node", "--check", file], tree)
|
||||
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
||||
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
|
||||
run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py"], tree)
|
||||
run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/deploy_staging.py", "scripts/reencode_image.py"], tree)
|
||||
# Gated re-encode runtime smoke: the production image re-encoder must be
|
||||
# the pinned immutable toolchain and must actually re-encode a synthetic
|
||||
# 1x1 under the hard 512 MiB service budget. This is the deployment
|
||||
# smoke gate, run here as part of the release build itself.
|
||||
deploy_mod = run([sys.executable, "-c", "import importlib.util,sys; s=importlib.util.spec_from_file_location('d','scripts/deploy_staging.py'); m=importlib.util.module_from_spec(s); s.loader.exec_module(m); print(__import__('json').dumps(m.verify_image_runtime(__import__('pathlib').Path('.'))))"], tree, capture=True)
|
||||
run(["git", "diff", "--check", commit], tree)
|
||||
|
||||
names = tracked_files(tree)
|
||||
|
||||
@@ -238,6 +238,59 @@ def _run_argv(argv: Sequence[str], **options) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(list(argv), check=True, text=True, capture_output=True, shell=False, timeout=timeout)
|
||||
|
||||
|
||||
# Pinned, immutable production image runtime. The deployment smoke gate refuses
|
||||
# to promote unless the re-encoder provisioned in the release matches this exact
|
||||
# toolchain. This is the same record scripts/reencode_image.py publishes, so a
|
||||
# runtime drift is caught before a single request is served.
|
||||
PINNED_PYTHON_VERSION = "3.11"
|
||||
PINNED_PILLOW_VERSION = "12.3.0"
|
||||
REENCODE_SCRIPT = "scripts/reencode_image.py"
|
||||
|
||||
|
||||
def verify_image_runtime(root: Path, *, timeout: float = 30.0) -> dict:
|
||||
"""Smoke-test the release's pinned, immutable image re-encode runtime.
|
||||
|
||||
Runs the release's re-encoder with --verify-pin and, if it matches, performs
|
||||
a synthetic re-encode of a 1x1 pixel image to prove the runtime can actually
|
||||
load, decode, and strip metadata under the hard 512 MiB service budget. No
|
||||
live host or network change: this only exercises the release on disk.
|
||||
"""
|
||||
script = root / REENCODE_SCRIPT
|
||||
if not script.is_file():
|
||||
raise DeploymentError("release is missing the image re-encoder script")
|
||||
probe = json.loads(subprocess.run(
|
||||
[sys.executable, str(script), "--verify-pin"],
|
||||
check=True, text=True, capture_output=True, shell=False, timeout=timeout,
|
||||
).stdout.strip())
|
||||
if probe.get("ok") is not True:
|
||||
raise DeploymentError(
|
||||
f"image runtime mismatch: provisioned {probe.get('python')}/{probe.get('pillow')} "
|
||||
f"does not match pinned {PINNED_PYTHON_VERSION}/{PINNED_PILLOW_VERSION}"
|
||||
)
|
||||
import base64
|
||||
# Minimal valid 1x1 baseline JPEG (no metadata) used only for the smoke.
|
||||
# Generated with Pillow to avoid an embed errors in a hand-written blob.
|
||||
from PIL import Image as _PILImage
|
||||
import io as _io
|
||||
_buf = _io.BytesIO()
|
||||
_PILImage.new("RGB", (1, 1), (128, 128, 128)).save(_buf, "JPEG", quality=85)
|
||||
pixel = _buf.getvalue()
|
||||
with tempfile.TemporaryDirectory(prefix="timmy-runtime-smoke-") as work:
|
||||
src = Path(work) / "pixel.jpg"
|
||||
dst = Path(work) / "reencoded.jpg"
|
||||
src.write_bytes(pixel)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--in", str(src), "--out", str(dst)],
|
||||
check=True, text=True, capture_output=True, shell=False, timeout=timeout,
|
||||
)
|
||||
verdict = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
if not verdict.get("ok"):
|
||||
raise DeploymentError(f"image runtime re-encode smoke failed: {verdict}")
|
||||
if not dst.is_file() or dst.stat().st_size == 0 or dst.read_bytes()[:3] != b"\xff\xd8\xff":
|
||||
raise DeploymentError("image runtime re-encode smoke produced no valid JPEG")
|
||||
return {"ok": True, "python": probe["python"], "pillow": probe["pillow"]}
|
||||
|
||||
|
||||
def poll_health(url: str, expected_commit: str, timeout: float) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
@@ -265,6 +318,9 @@ def _verify(config: DeploymentConfig, commit: str, run_command: RunCommand, heal
|
||||
health_check(config.health_url, commit, config.health_timeout)
|
||||
if smoke:
|
||||
run_command(config.smoke_command, check=True, text=True, capture_output=True, shell=False, timeout=config.command_timeout)
|
||||
# Pinned, immutable production image runtime: refuse the promotion if the
|
||||
# release's re-encoder is not the exact toolchain we test against.
|
||||
verify_image_runtime(config.releases / commit, timeout=config.command_timeout)
|
||||
|
||||
|
||||
def promote(*, config: DeploymentConfig, tag: str, archive: Path, expected_sha256: str, commit: str,
|
||||
|
||||
@@ -77,4 +77,54 @@ 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")
|
||||
+120
-32
@@ -2,76 +2,164 @@
|
||||
"""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]
|
||||
reencode_image.py --in SOURCE --out TARGET [--max-bytes N]
|
||||
[--max-dimension N] [--max-pixels 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.
|
||||
Reads SOURCE, rejects oversized geometry from the container header BEFORE any
|
||||
pixel decode, then decodes defensively, 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.
|
||||
|
||||
Exit codes are a contract the caller maps to client-visible outcomes:
|
||||
0 success
|
||||
2 rejected hostile/invalid input (client's fault)
|
||||
3 processing unavailable (server runtime fault, not the client's)
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
Image = None # populated in main() so import errors fail closed
|
||||
EXIT_OK = 0
|
||||
EXIT_REJECTED = 2
|
||||
EXIT_UNAVAILABLE = 3
|
||||
|
||||
# Pinned, immutable production image runtime. This is the exact Python/Pillow
|
||||
# toolchain the service re-encoder is built and tested against; the deployment
|
||||
# smoke gate (scripts/deploy_staging.py and CI) verifies the provisioned
|
||||
# runtime matches this record before promoting. Changing the runtime must bump
|
||||
# this pin in lockstep with requirements-test.txt (Pillow==12.3.0) and the
|
||||
# re-encode resource contract.
|
||||
PINNED_PYTHON_VERSION = "3.11"
|
||||
PINNED_PILLOW_VERSION = "12.3.0"
|
||||
|
||||
# Default total-pixel ceiling. Bounded independently of the per-dimension cap so
|
||||
# wide-and-short or tall-and-narrow geometry cannot smuggle a huge pixel budget
|
||||
# past a per-side check. 4096*4096 matches the accepted dimension envelope.
|
||||
DEFAULT_MAX_PIXELS = 4096 * 4096
|
||||
|
||||
|
||||
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("--in", dest="source")
|
||||
p.add_argument("--out", dest="target")
|
||||
p.add_argument("--max-bytes", type=int, default=4 * 1024 * 1024)
|
||||
p.add_argument("--max-dimension", type=int, default=4096)
|
||||
p.add_argument("--max-pixels", type=int, default=DEFAULT_MAX_PIXELS)
|
||||
p.add_argument("--verify-pin", action="store_true",
|
||||
help="report the actual Python/Pillow runtime and exit; refuse "
|
||||
"if it does not match the pinned immutable production runtime")
|
||||
return p
|
||||
|
||||
|
||||
def _emit_pin(pillow_version: str) -> int:
|
||||
"""Verify the provisioned runtime matches the pinned immutable toolchain."""
|
||||
actual_python = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
matched = (actual_python == PINNED_PYTHON_VERSION and pillow_version == PINNED_PILLOW_VERSION)
|
||||
print(json.dumps({
|
||||
"ok": matched,
|
||||
"python": actual_python,
|
||||
"pillow": pillow_version,
|
||||
"pinned": {"python": PINNED_PYTHON_VERSION, "pillow": PINNED_PILLOW_VERSION},
|
||||
}))
|
||||
# A runtime that is not the pinned production toolchain is a server-side
|
||||
# provisioning fault, not client input: map to processing-unavailable.
|
||||
return EXIT_OK if matched else EXIT_UNAVAILABLE
|
||||
|
||||
|
||||
def reject(error: str) -> int:
|
||||
print(json.dumps({"ok": False, "error": error}))
|
||||
return EXIT_REJECTED
|
||||
|
||||
|
||||
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
|
||||
# Server-side runtime fault: the caller must map this to
|
||||
# processing-unavailable, never to "your photo is corrupt".
|
||||
print(json.dumps({"ok": False, "error": "unavailable"}))
|
||||
return EXIT_UNAVAILABLE
|
||||
|
||||
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
|
||||
# Pin verification mode: the deployment smoke gate invokes the re-encoder
|
||||
# with --verify-pin to prove the provisioned, immutable runtime is exactly
|
||||
# the one the service was built and tested against. No live host change.
|
||||
if args.verify_pin:
|
||||
return _emit_pin(getattr(_Image, "__version__", "unknown"))
|
||||
|
||||
# Decompression-bomb warnings are errors here. Pillow's default threshold
|
||||
# only warns and then hands back a fully decoded image, which is not a
|
||||
# rejection; promote it so any bomb path raises instead.
|
||||
warnings.simplefilter("error", _Image.DecompressionBombWarning)
|
||||
# Refuse to let Pillow allocate beyond our own accepted pixel envelope.
|
||||
_Image.MAX_IMAGE_PIXELS = max(1, args.max_pixels)
|
||||
|
||||
try:
|
||||
# Fail closed on decompression bombs before full pixel load.
|
||||
source_size = os.path.getsize(args.source)
|
||||
except OSError:
|
||||
return reject("rejected")
|
||||
if source_size <= 0 or source_size > args.max_bytes:
|
||||
return reject("rejected")
|
||||
|
||||
# --- Header-only geometry gate, before any pixel decode. -----------------
|
||||
# Image.open() parses the header lazily, so probe.size is available without
|
||||
# allocating the pixel buffer. Rejecting here keeps a 12000x12000 bomb at
|
||||
# header cost instead of ~163 MiB of decoded pixels.
|
||||
try:
|
||||
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 _Image.DecompressionBombWarning:
|
||||
return reject("dimensions")
|
||||
except _Image.DecompressionBombError:
|
||||
return reject("dimensions")
|
||||
except Exception:
|
||||
print(json.dumps({"ok": False, "error": "malformed"}))
|
||||
return 2
|
||||
return reject("malformed")
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
return reject("malformed")
|
||||
if width > args.max_dimension or height > args.max_dimension:
|
||||
return reject("dimensions")
|
||||
if width * height > args.max_pixels:
|
||||
return reject("dimensions")
|
||||
|
||||
# --- Only now is a full decode allowed. ---------------------------------
|
||||
try:
|
||||
with _Image.open(args.source) as probe:
|
||||
probe.load()
|
||||
# Re-check after decode: a hostile container can declare small
|
||||
# geometry in its header and expand during decode.
|
||||
if probe.size[0] > args.max_dimension or probe.size[1] > args.max_dimension:
|
||||
return reject("dimensions")
|
||||
if probe.size[0] * probe.size[1] > args.max_pixels:
|
||||
return reject("dimensions")
|
||||
image = probe.convert("RGB")
|
||||
except (_Image.DecompressionBombWarning, _Image.DecompressionBombError):
|
||||
return reject("dimensions")
|
||||
except Exception:
|
||||
return reject("malformed")
|
||||
|
||||
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
|
||||
return reject("encode")
|
||||
|
||||
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
|
||||
return reject("encode")
|
||||
|
||||
with open(args.target, "wb") as handle:
|
||||
handle.write(data)
|
||||
try:
|
||||
with open(args.target, "wb") as handle:
|
||||
handle.write(data)
|
||||
except OSError:
|
||||
# Cannot write the private temp target: server-side fault.
|
||||
print(json.dumps({"ok": False, "error": "unavailable"}))
|
||||
return EXIT_UNAVAILABLE
|
||||
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
@@ -81,7 +169,7 @@ def main() -> int:
|
||||
"bytes": len(data),
|
||||
"metadataStripped": True,
|
||||
}))
|
||||
return 0
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user