diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml
index c77cc35..35f7dd4 100644
--- a/.gitea/workflows/quality.yml
+++ b/.gitea/workflows/quality.yml
@@ -23,16 +23,22 @@ jobs:
with:
node-version: 22
cache: npm
+ - name: Set up pinned Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
- name: Install reproducibly
run: |
npm ci
python3 -m pip install --break-system-packages -r requirements-test.txt
+ printf 'TIMMY_PYTHON=%s\n' "$(python3 -c 'import sys; print(sys.executable)')" >> "$GITHUB_ENV"
- name: Install browser
run: npx playwright install --with-deps chromium
- name: Unit and security tests
run: |
npm test
python3 tests/staging-deploy.test.py -v
+ python3 tests/reencode-image.test.py -v
- name: Mobile browser acceptance
run: |
npm start > /tmp/timmy-server.log 2>&1 &
@@ -57,5 +63,7 @@ jobs:
run: |
npm run check:syntax
node --check tests/staging.acceptance.mjs
+ - name: Image runtime pin and re-encode smoke
+ run: python3 -c "import importlib.util,json,pathlib,sys; s=importlib.util.spec_from_file_location('d','scripts/deploy_staging.py'); m=importlib.util.module_from_spec(s); sys.modules[s.name]=m; s.loader.exec_module(m); print(json.dumps(m.verify_image_runtime(pathlib.Path('.'))))"
- name: Diff hygiene
run: npm run check:diff
diff --git a/package.json b/package.json
index 31839e0..2d32133 100644
--- a/package.json
+++ b/package.json
@@ -4,12 +4,12 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
+ "test": "node --test tests/domain.test.js tests/analysis.test.js tests/image-ingress.test.js tests/rate-limiter.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js 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",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:staging-smoke": "node tests/staging.acceptance.mjs",
- "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
+ "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/image-ingress.js && node --check src/rate-limiter.js && node --check src/client-identity.js && node --check src/image-container.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/image-polyglot.test.js && node --check tests/image-dataurl.test.js && node --check tests/image-concurrency.test.js && node --check tests/image-unavailable.test.js && node --check tests/rate-identity.test.js && node --check tests/body-timeout.test.js && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/reencode_image.py",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs"
},
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 5d2b391..e7ddbcb 100755
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -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)
diff --git a/scripts/deploy_staging.py b/scripts/deploy_staging.py
index 5d98212..69258d8 100755
--- a/scripts/deploy_staging.py
+++ b/scripts/deploy_staging.py
@@ -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,
diff --git a/scripts/gen_ingress_fixtures.py b/scripts/gen_ingress_fixtures.py
new file mode 100644
index 0000000..2147977
--- /dev/null
+++ b/scripts/gen_ingress_fixtures.py
@@ -0,0 +1,130 @@
+#!/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"
not an image")
+
+# 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"" * 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'")
+
+# --- 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"",
+ "ingress-tail-mixed-script.jpg": b"",
+ "ingress-tail-html.jpg": b"
",
+ "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"")
+
+# 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"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")
diff --git a/scripts/reencode_image.py b/scripts/reencode_image.py
new file mode 100644
index 0000000..b1bc59e
--- /dev/null
+++ b/scripts/reencode_image.py
@@ -0,0 +1,176 @@
+#!/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] [--max-pixels N]
+
+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
+
+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")
+ 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:
+ # 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
+
+ # 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:
+ 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:
+ width, height = probe.size
+ except _Image.DecompressionBombWarning:
+ return reject("dimensions")
+ except _Image.DecompressionBombError:
+ return reject("dimensions")
+ except Exception:
+ 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:
+ return reject("encode")
+
+ data = buffer.getvalue()
+ if not data or data[:3] != b"\xff\xd8\xff" or len(data) > args.max_bytes:
+ return reject("encode")
+
+ 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,
+ "format": "jpeg",
+ "width": image.size[0],
+ "height": image.size[1],
+ "bytes": len(data),
+ "metadataStripped": True,
+ }))
+ return EXIT_OK
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/server.mjs b/server.mjs
index 82f2ba1..46cd33c 100644
--- a/server.mjs
+++ b/server.mjs
@@ -4,9 +4,19 @@ import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzePhoto } from './src/vision-service.js';
+import { createRateLimiter } from './src/rate-limiter.js';
+import { resolveClientIdentity, resolveTrustedProxies } from './src/client-identity.js';
+import { IngressUnavailableError } from './src/image-ingress.js';
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
+const analyzeRateLimiter=createRateLimiter();
+// Explicit rate-limit identity policy. Behind a reverse proxy the socket peer is
+// the proxy itself, so a forwarded client address is only honoured when the peer
+// is in this configured allowlist. Otherwise the peer address is used, and a
+// trusted proxy that forwards nothing usable degrades to a truthful shared quota.
+const trustedProxies=resolveTrustedProxies(process.env.TIMMY_TRUSTED_PROXIES);
+
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
const host=process.env.HOST||'0.0.0.0';
@@ -32,19 +42,105 @@ const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${c
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&','<':'<','>':'>','"':'"'}[character]));}
-function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
+const BODY_READ_TIMEOUT_MS = Math.max(500, Number.parseInt(process.env.TIMMY_BODY_READ_TIMEOUT_MS || '10000', 10) || 10000);
+const MAX_BODY_BYTES = 6 * 1024 * 1024;
+
+/**
+ * Read a JSON request body with a hard inbound timeout and a stop-on-oversize
+ * guard. The connection is actively destroyed the moment either limit trips, so
+ * a hostile client cannot hold a request open by dribbling bytes or force the
+ * server to drain an enormous payload before rejecting it.
+ */
+function readJson(req, res, maxBytes = MAX_BODY_BYTES) {
+ return new Promise((resolve, reject) => {
+ let size = 0;
+ let tooLarge = false;
+ let settled = false;
+ const chunks = [];
+ let timer = null;
+
+ const fail = (error) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ req.off('data', onData);
+ req.off('end', onEnd);
+ req.off('error', onError);
+ reject(error);
+ };
+
+ const onData = (chunk) => {
+ if (settled || tooLarge) return;
+ size += chunk.length;
+ if (size > maxBytes) {
+ tooLarge = true;
+ // Reject immediately so the transport can answer a sanitized 413 without
+ // waiting for the client to finish streaming an enormous payload. The
+ // socket is torn down by the caller after the response is written.
+ return fail(new AgentGatewayError(413, 'Request is too large. Use a smaller photo or continue manually.'));
+ }
+ chunks.push(chunk);
+ };
+ const onEnd = () => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ if (tooLarge) return;
+ try {
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
+ } catch {
+ reject(new AgentGatewayError(400, 'Invalid JSON request.'));
+ }
+ };
+ const onError = (error) => {
+ // A broken socket is torn down here; the rejection still propagates.
+ try { req.destroy(); } catch { /* already gone */ }
+ fail(error);
+ };
+
+ timer = setTimeout(() => {
+ // Hard inbound timeout: a slow-dribbling client must not hold the
+ // connection open. The caller answers 408 and destroys the socket.
+ fail(new AgentGatewayError(408, 'Request timed out while reading the upload.'));
+ }, BODY_READ_TIMEOUT_MS);
+
+ req.on('data', onData);
+ req.on('end', onEnd);
+ req.on('error', onError);
+ });
+}
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
+// Ingress failures carry their own honest classification: a server-side
+// processing fault must not be reported as corrupt client input.
+function handleAnalyzeError(res,error){
+ if(error instanceof IngressUnavailableError||error?.ingressUnavailable===true)return sendJson(res,503,{error:error.message,manualFallback:true});
+ const safe=/consent|JPEG|PNG|WebP|empty|too large|supported image|corrupt|malformed|slow down/i.test(error.message);
+ return sendJson(res,safe?400:503,{error:error.message});
+}
+// A body-read rejection (oversized or timed out) is answered with a sanitized
+// status and then the socket is torn down so a hostile client cannot keep the
+// request open by dribbling or draining an enormous payload.
+function handleBodyError(res,req,error){
+ if(typeof error?.status==='number'){sendJson(res,error.status,{error:error.message});}
+ else{sendJson(res,400,{error:'Invalid request.'});}
+ try{res.end();req.destroy();}catch{/* already closed */}
+}
+
http.createServer(async(req,res)=>{
try{
const url=new URL(req.url,'http://localhost');
if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')}
if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()}
const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname;
+ // Nested API routes under the base path (e.g. /timmy-staging/api/healthz) are
+ // routed to their canonical /api/* handler so the base path is preserved on
+ // every operational endpoint, not just the root.
+ const apiPath=basePath&&appPath.startsWith('/api/')?appPath:appPath;
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
if(appPath==='/api/vision-status'&&req.method==='GET'){
const provider=await probeVisionProvider(visionConfig);
@@ -53,9 +149,13 @@ http.createServer(async(req,res)=>{
}
if(appPath==='/api/analyze'&&req.method==='POST'){
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
- const payload=await readJson(req);
- try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
- catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})}
+ const identity=resolveClientIdentity({remoteAddress:req.socket?.remoteAddress,headers:req.headers,trustedProxies});
+ const limit=analyzeRateLimiter.take(identity.key);
+ if(!limit.allowed){
+ res.setHeader('retry-after',Math.ceil(limit.retryAfterMs/1000));
+ return sendJson(res,429,{error:'Too many photo analyses. Please slow down and try again later.'});
+ }
+ try{const payload=await readJson(req,res);try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}catch(error){return handleAnalyzeError(res,error)}}catch(error){return handleBodyError(res,req,error)}
}
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
diff --git a/src/client-identity.js b/src/client-identity.js
new file mode 100644
index 0000000..b5c2c82
--- /dev/null
+++ b/src/client-identity.js
@@ -0,0 +1,70 @@
+// Explicit client identity for rate limiting.
+//
+// Behind a reverse proxy the socket peer is the proxy itself, so keying on
+// req.socket.remoteAddress silently collapses every user into one quota. The
+// policy here is explicit and fails safe:
+//
+// * If the peer is NOT a configured trusted proxy, forwarded headers are
+// ignored entirely — they are attacker-controlled and would let anyone mint
+// unlimited identities or impersonate another client.
+// * If the peer IS a configured trusted proxy, the left-most syntactically
+// valid address in the forwarded chain is used as the client identity.
+// * If a trusted proxy forwards nothing usable, the scope degrades to an
+// honest shared 'global' quota rather than pretending to be per-client.
+//
+// The returned scope is reported truthfully so operators and health output can
+// state which policy is actually in force.
+import { isIP } from 'node:net';
+
+const MAX_KEY_LENGTH = 64;
+
+function normaliseAddress(value) {
+ const raw = String(value || '').trim();
+ if (!raw || raw.length > MAX_KEY_LENGTH) return null;
+ // Strip an IPv6 zone index and IPv4-mapped IPv6 prefix for stable keys.
+ const withoutZone = raw.replace(/%.*$/, '');
+ const unmapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(withoutZone);
+ const candidate = unmapped ? unmapped[1] : withoutZone;
+ return isIP(candidate) ? candidate : null;
+}
+
+/** Parse the trusted-proxy allowlist from configuration. */
+export function resolveTrustedProxies(value) {
+ return String(value || '')
+ .split(',')
+ .map((entry) => normaliseAddress(entry))
+ .filter((entry) => entry !== null);
+}
+
+/**
+ * Decide the rate-limit identity for one request.
+ *
+ * Returns `{ key, scope, trustedProxy }` where scope is one of:
+ * 'peer' keyed on the directly connected address
+ * 'forwarded' keyed on a client address supplied by a trusted proxy
+ * 'global' one shared quota, stated honestly
+ */
+export function resolveClientIdentity({ remoteAddress, headers = {}, trustedProxies = [] } = {}) {
+ const peer = normaliseAddress(remoteAddress);
+ const allowlist = trustedProxies.map((entry) => normaliseAddress(entry)).filter(Boolean);
+ const trusted = peer !== null && allowlist.includes(peer);
+
+ if (!trusted) {
+ // Untrusted peer: forwarded headers are spoofable and must be ignored.
+ if (peer === null) return { key: 'global', scope: 'global', trustedProxy: false };
+ return { key: peer, scope: 'peer', trustedProxy: false };
+ }
+
+ const forwardedFor = headers['x-forwarded-for'] ?? headers['X-Forwarded-For'];
+ const chain = String(forwardedFor || '').split(',');
+ for (const entry of chain) {
+ const client = normaliseAddress(entry);
+ if (client) return { key: client, scope: 'forwarded', trustedProxy: true };
+ }
+ const realIp = normaliseAddress(headers['x-real-ip'] ?? headers['X-Real-IP']);
+ if (realIp) return { key: realIp, scope: 'forwarded', trustedProxy: true };
+
+ // A trusted proxy that forwards nothing usable means the quota really is
+ // shared. Say so rather than reporting a per-client guarantee we cannot keep.
+ return { key: 'global', scope: 'global', trustedProxy: true };
+}
diff --git a/src/image-container.js b/src/image-container.js
new file mode 100644
index 0000000..03f8ac4
--- /dev/null
+++ b/src/image-container.js
@@ -0,0 +1,141 @@
+// Canonical image container boundary.
+//
+// Polyglot and trailing-data rejection must be structural: parse the declared
+// container and require that its own structure consumes exactly the supplied
+// bytes. Substring scanning for signatures like `