From ccb227921e7e169a312e92aa769cbc2bffef530e Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 21:43:36 +0000 Subject: [PATCH 1/4] 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 --- package.json | 4 +- scripts/gen_ingress_fixtures.py | 80 ++++++++++++ scripts/reencode_image.py | 88 +++++++++++++ server.mjs | 11 +- src/image-ingress.js | 114 ++++++++++++++++ src/rate-limiter.js | 31 +++++ src/vision-service.js | 7 +- tests/fixtures/ingress-bomb.png | Bin 0 -> 168456 bytes tests/fixtures/ingress-clean.jpg | Bin 0 -> 1162 bytes tests/fixtures/ingress-empty.jpg | 0 tests/fixtures/ingress-exif.jpg | Bin 0 -> 1332 bytes tests/fixtures/ingress-garbage.jpg | Bin 0 -> 2048 bytes tests/fixtures/ingress-metadata.png | Bin 0 -> 225 bytes tests/fixtures/ingress-oversized.jpg | Bin 0 -> 563126 bytes tests/fixtures/ingress-polyglot.gif | Bin 0 -> 126 bytes tests/fixtures/ingress-script.svg | 1 + tests/fixtures/ingress-spoofed.html | 1 + tests/fixtures/ingress-truncated.jpg | Bin 0 -> 581 bytes tests/fixtures/ingress-zip-polyglot.jpg | Bin 0 -> 36 bytes tests/image-ingress.test.js | 164 ++++++++++++++++++++++++ tests/rate-limiter.test.js | 26 ++++ tests/vision-service.test.js | 20 ++- 22 files changed, 535 insertions(+), 12 deletions(-) create mode 100644 scripts/gen_ingress_fixtures.py create mode 100644 scripts/reencode_image.py create mode 100644 src/image-ingress.js create mode 100644 src/rate-limiter.js create mode 100644 tests/fixtures/ingress-bomb.png create mode 100644 tests/fixtures/ingress-clean.jpg create mode 100644 tests/fixtures/ingress-empty.jpg create mode 100644 tests/fixtures/ingress-exif.jpg create mode 100644 tests/fixtures/ingress-garbage.jpg create mode 100644 tests/fixtures/ingress-metadata.png create mode 100644 tests/fixtures/ingress-oversized.jpg create mode 100644 tests/fixtures/ingress-polyglot.gif create mode 100644 tests/fixtures/ingress-script.svg create mode 100644 tests/fixtures/ingress-spoofed.html create mode 100644 tests/fixtures/ingress-truncated.jpg create mode 100644 tests/fixtures/ingress-zip-polyglot.jpg create mode 100644 tests/image-ingress.test.js create mode 100644 tests/rate-limiter.test.js diff --git a/package.json b/package.json index 31839e0..ae71401 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", "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/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 scripts/reencode_image.py", "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs" }, diff --git a/scripts/gen_ingress_fixtures.py b/scripts/gen_ingress_fixtures.py new file mode 100644 index 0000000..a88d798 --- /dev/null +++ b/scripts/gen_ingress_fixtures.py @@ -0,0 +1,80 @@ +#!/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'' + b"") + +print("fixtures written") diff --git a/scripts/reencode_image.py b/scripts/reencode_image.py new file mode 100644 index 0000000..76a3592 --- /dev/null +++ b/scripts/reencode_image.py @@ -0,0 +1,88 @@ +#!/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()) diff --git a/server.mjs b/server.mjs index 82f2ba1..ca1ec59 100644 --- a/server.mjs +++ b/server.mjs @@ -4,9 +4,12 @@ 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 { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js'; import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js'; +const analyzeRateLimiter=createRateLimiter(); + const root=fileURLToPath(new URL('.',import.meta.url)); const port=Number(process.env.PORT||4173); const host=process.env.HOST||'0.0.0.0'; @@ -53,9 +56,15 @@ 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 clientKey=(req.socket&&req.socket.remoteAddress)||'unknown'; + const limit=analyzeRateLimiter.take(clientKey); + 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.'}); + } 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})} + catch(error){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})} } 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/image-ingress.js b/src/image-ingress.js new file mode 100644 index 0000000..5b8e879 --- /dev/null +++ b/src/image-ingress.js @@ -0,0 +1,114 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +export const MAX_IMAGE_DIMENSION = 4096; +const REENCODE_TIMEOUT_MS = 15_000; + +const MAGIC = { + jpeg: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, + png: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47, + webp: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP', +}; + +function base64Bytes(dataUrl) { + const match = /^data:[^;,]*;base64,([A-Za-z0-9+/=\r\n]+)$/.exec(String(dataUrl || '')); + if (!match) return null; + const clean = match[1].replace(/[\r\n]/g, ''); + return Buffer.from(clean, 'base64'); +} + +export function sniffImageFormat(bytes) { + if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null; + for (const [format, test] of Object.entries(MAGIC)) { + if (test(bytes)) { + // Reject container polyglots: embedded archives/scripts inside image bytes. + if (bytes.includes(Buffer.from('PK\x03\x04'))) return null; + if (bytes.includes(Buffer.from(' { + execFile( + process.env.TIMMY_PYTHON || 'python3', + [ + join(fileURLToPath(new URL('..', import.meta.url)), 'scripts', 'reencode_image.py'), + '--in', sourcePath, + '--out', targetPath, + '--max-bytes', String(MAX_IMAGE_BYTES), + '--max-dimension', String(MAX_IMAGE_DIMENSION), + ], + { timeout: REENCODE_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + if (error) { + if (error.killed || error.signal === 'SIGTERM') return reject(sanitizedError('unavailable')); + // Exit code 2 with a dimensions verdict maps to a distinct message. + if (/dimensions/.test(String(stdout))) return reject(sanitizedError('dimensions')); + return reject(sanitizedError('corrupt')); + } + try { + resolve(JSON.parse(String(stdout).trim())); + } catch { + reject(sanitizedError('corrupt')); + } + }, + ); + }); +} + +export async function validateImageIngress(payload = {}) { + if (payload.consent !== true) throw sanitizedError('consent'); + const bytes = base64Bytes(payload.imageDataUrl); + if (!bytes || bytes.length === 0) throw sanitizedError('format'); + if (bytes.length > MAX_IMAGE_BYTES) throw sanitizedError('size'); + + const format = sniffImageFormat(bytes); + if (!format) throw sanitizedError('format'); + + const workDir = await mkdtemp(join(tmpdir(), 'timmy-ingress-')); + try { + const sourcePath = join(workDir, `source.${format}`); + const targetPath = join(workDir, 'reencoded.jpg'); + await writeFile(sourcePath, bytes); + let result; + try { + result = await reencode(sourcePath, targetPath); + } catch (error) { + throw error; + } + const reencoded = await readFile(targetPath); + return { + imageDataUrl: `data:image/jpeg;base64,${reencoded.toString('base64')}`, + mime: 'image/jpeg', + format: result.format, + width: result.width, + height: result.height, + bytes: reencoded.length, + originalFormat: format, + metadataStripped: result.metadataStripped === true, + }; + } finally { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/src/rate-limiter.js b/src/rate-limiter.js new file mode 100644 index 0000000..d56a01e --- /dev/null +++ b/src/rate-limiter.js @@ -0,0 +1,31 @@ +// Fixed-window in-memory rate limiter for image analysis requests. +// Bounded: per-key counters only, no payloads stored. + +export function createRateLimiter({ windowMs = 60_000, maxRequests = 10 } = {}) { + const windows = new Map(); + + function take(key, now = Date.now()) { + let entry = windows.get(key); + if (!entry || now >= entry.resetAt) { + entry = { count: 0, resetAt: now + windowMs }; + windows.set(key, entry); + } + entry.count += 1; + if (windows.size > 10_000) { + // Bound memory: drop expired entries when the map grows too large. + for (const [existingKey, existing] of windows) { + if (now >= existing.resetAt) windows.delete(existingKey); + } + } + if (entry.count > maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(1, entry.resetAt - now), + reason: 'Too many photo analyses. Please slow down and try again later.', + }; + } + return { allowed: true, remaining: maxRequests - entry.count }; + } + + return { take, limit: maxRequests, windowMs }; +} diff --git a/src/vision-service.js b/src/vision-service.js index 6edb789..c34c141 100644 --- a/src/vision-service.js +++ b/src/vision-service.js @@ -1,4 +1,5 @@ -import { buildVisionRequest, parseVisionResponse, validatePhotoPayload } from './analysis.js'; +import { validateImageIngress } from './image-ingress.js'; +import { buildVisionRequest, parseVisionResponse } from './analysis.js'; function providerEndpoint(baseUrl) { let url; @@ -8,7 +9,9 @@ function providerEndpoint(baseUrl) { } export async function analyzePhoto({ payload, fetchImpl = fetch, config }) { - const photo = validatePhotoPayload(payload); + // Hardened ingress first: consent, magic bytes, limits, safe re-encode, + // metadata stripping. All failures happen before any provider work. + const photo = await validateImageIngress(payload); if (!config?.model) throw new Error('AI analysis is not configured.'); const endpoint = providerEndpoint(config.baseUrl); const response = await fetchImpl(endpoint, { diff --git a/tests/fixtures/ingress-bomb.png b/tests/fixtures/ingress-bomb.png new file mode 100644 index 0000000000000000000000000000000000000000..f9ef2066ebc97b3b9daa51c6c5c238bae03082e4 GIT binary patch literal 168456 zcmeI*PiWV59LMpGI{)N9EJ7q44eHRr!k~k&3KxsH4*kQ!K!l+u^PoY@h`=>36}0Tw zrAvoSi919>2Pc$z>JpTQkVHh_ED$^h68+w8uisYP@)y;34CMFwlzxBi@q51C--Ba& z=f&qoHm!ePeQRyg=$_%1TYKzk{aV-g5AC(bzIn8*Xl-sCi>KXFUa+fyv`ZlxbC?*6MI`J}cZ+Td#-dB%ZsBf` z7>q?E+%4QKx-A%sE`_^=yG3F!7LjnbaJT5TU@W>6?iTJAiNRPz!rj8%qT7P8=u)^_ zxLYI!V-X2=3wMid3&x^L;cnq>kr<3cB-|~Q=iTDXiR)wa9*>_qd-I8j`$rbv@6!WI zzh2BKG9*Z}rUDhHAPtZpQ3AGNIv_#9Jfr~E2aYyB+Nq^AVH!8 zY{hgyf`oZU10+b4fUTGgNRTiOX@CTY60jB10g1ma@%H|~t@Wq3{dnZ5_xiuOcm7H{ z)-C-pl}_;`L83Jks6Yj2fCPyWuocq*2@>WZ4UiyF0=8m0AVI=BqyZ8nO2Af32P83{?Y^N?6LmD7Kq6BQk zbU=cHc}N2!NR)uBm<~vgFb`>f1c?%`71IHUUP>JJZhCcHZ_TZJFB}|swXd}eQ-gVd z&ZAQ>NRTiOX@CTY60jB10SOZ3Aq|iqQ3AGNIv_#9Jfr~rY?>Pw)C9u3_-{+@tmHVoT`6;mLvJFU(_^;m^|N70w z7T&YjsjHjw_H}-Cr4ttwbO*vbgn|SK_lGn50K!SvMNCPBDlz^?64oHwN4{6|TCI0-)#&X@QeaG^& z^XL=|5+uw+8X!TU1Z>50K!SvMNCPBDlz^?64oHwN4{3k|i4w3C(*X$*<{=G`AW;Ig zVmcr}!aSq_5+q8%R!j#ZNSKE-K!QXG*ox`kE+r-}eRi;Z80F;9Z{L1%>80)SKbZ3L zz?oHff!3U2L4t&NNCPBDlz^?64oHwN4{3k|i4w3C(*X$*<{=G`AW;IgVmcr}!aSq_ z5+q8%R!j#ZNSKE-K!QXG*ox_Z1PSwy21t-70b4O0+`Yt?r}{V4)i&Pv{G;>JKdkFK zom_pcKQG`!q69RN4oHwN4{3k|i4w3C(*X$*<{=G`AW;IgVmcr}!aSq_5+q8%R!j#Z zNSKE-K!QXG*ox_Z1PSwy21t-70b4O0kRV|m(g2CxN(@X~AFC^lpFDf>iHZA1THCSi zm&KQG>RNCK1ql-NL>eGLq6BQkbU=cHc}N2!NR)uBm<~vgFb`>f1c?%`71IF;66PTd zkRVY4wqiOULBc$w0TLuiz*bBLBuJQtG(dtx3G`+w&K$n5vaWXG_NSlBe7x(x(9yMZ zoc|biBGFV}9y(u8Y8TiAD#$7zL82pID<%UHB+Nq^AVH!8Y{hgyf`oZU10+b4fUTGg zNRTiOX@CTY60jB10SOZ3Aq|iqQ3AGNIv_#9JfwktPU5fMU@Wh>VQMf>JC9DmAVI=B zqyZ8nO2Af32P843!lU*hbC$F{Zh!RMpHLwobtXq-d|nD2Bzf`oZU z10+b4fUTGgNRTiOX@CTY60jB10SOZ3Aq|iqQ3AGNIw0}yO8n`YjotaiW1YXz*p@3R z@)vU=Q39Gs2P83{?Y^NE2aYyB+Nq^ zAVH!8Y{hgyf`oZU10+b4fUTGgNRTiOX@ErUB;MXXxV5gf?Z+cez1RQMy?3rWJ9TyQ z;!8Nif&_`yRG3{?Y^NE2aYyB+Nq^AVH!8Y{hgyf`oZU10;GWv3u_FOkHnw;P|yebB|2d^R0cy>S5u- ziG(K700|N$U@N8r5+uw+8X!TU1Z>50K!SvMNCPBDlz^?64oHwN4{3k|i4w3C(*X$* z<{=G`AW;IgVmcr}!aSq_5+q8%R!j#ZdMWYBchjrudTVa&d*R^7t9|YCz?oHff#%eu q;1UWFBy5H>K!QXGEc>mv<;m^)FFgJ8PyO}Tjz0VR@Tq6Ur+x<${0y7` literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-clean.jpg b/tests/fixtures/ingress-clean.jpg new file mode 100644 index 0000000000000000000000000000000000000000..93a664d182623ce72c9c691bed436e6b8a1a5a8c GIT binary patch literal 1162 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 zV+eoUV&GwB1V$dSAcH-_pHEvZ?JA5mn-%UqZCkF%x;bHImrcF=bo~pf8|RHroi66x zYWUV<-F)4gTbZ_YQ8$5#YqoCAT=L~-_Mx>spY20YRK!}Wo$af=Ty%5h(vnAew16gj z&Dm~nJ6P_r*UO_o6OiSR4X~?+n!B{*XLcG;@uzDLS5$2?KJ%YJ_tTxaTQk1R@`pGP z*)fH2qB-d^f31t|$FyZD*cBju09_%!ZJ8<1V_mnvHf;n-{mjk+*>t@%TJl-l>S>R^ zUHSP7Z1vS;lP^DA+YR){>F6+!rF&li&G@t9bm8x;;4-YpNH#aYzkN_{AARjlkfS7=wu!yLrC=b85gqVngkcg-V$Ph+mpdJ=hPF7Y< z5ngUy5t70G0}O&33=Rwq%#2D5OoEKef{g!d!VGdXP_`B*&%h$cDx_%W$R-?^$gWfnAuRebI{N?Mn?>~P20{M#(m?B^RtG@(+{$gTbVP;_m`HPXM z9OQ997FI<=HX+AA_QXPAC8I_T5vPd@Hy-3vHV*nAnpAX=OH9S&q3TDF*T6m_&SOnv z`3&wcguiYv@Gt`tsvxrA zfF^v+*=}$$*L zX^+2M`S}ZM_0?sQFF#$|4fM$A=rE9_dtU*~__O15;qR>EsLWWqD3BqA(PFd0{j|}| zI0lvq14$);e4Pu{@L_eb&H1aFK;F0p4(YYvFohddv-M%vvC8?Yp6>X&)vj*KrK*~( zAlLuQPP*&~42-KFmwyMD@#((q@wY2`K|b3CG-FQKQBZ8HT@4C#uo0_MfkqgDr9iq6 m{s6`=Bt~LjVFA&08sraPz=B;=QMz$n`m3FJ_PPH5Zvp@Ydjq5Z literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-garbage.jpg b/tests/fixtures/ingress-garbage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dc8bb9d87092afd15aad76727f330ca531f9d979 GIT binary patch literal 2048 zcmV+b2>Qhx*@to^pLF9;3T>sRs3)e?nk7Pt@CkIbc=k|RA zLr`%!IeQQH-np5TYrg`X>gf`E$K0RUjqInf)A#ArXb6Q*qzD%?5^(D`?A{E<y(AdiUAVuQrgMGh~Hob;^xoe@CRr^UByFwHEC@t9CMfeNdb$h2h5e-jv-;j`; zHm=s`Rjw{{O6*Xhr%oLLF<4F4VwXG)LjAh|4{!oxu~YPj9j($nrTlaPqAVcAlh{3G z@Y4H@1#y+&|6&qH2OjKI4@+Zh^8D7jDkk^j$Ub7L)Nqg09&Mrh!1{K7&{MwU^*(6K z_aw&y%x943*i+tqc*#jko|IAQY75g6Xf`FLd@Icw^1A5&30XB>W>mX+BASw;qQ^ik zkqJ->1>w8Jts~0Tlc-Sb1an6_fq~cQmhEPssR|cn{Z|-ZV#MT9&7m23Vbb~XSCa1h8YoCnU>WFK zY46E=6NBzvm9dF|3oxi7Jo`kyfVZ~yVUc5c-@18CrE;QIL~BPd!DvL(Ic9uV(QJ;a z!3`!uhJV(y;b-Tba`ch8^N-*s-Xtv^7-{z1Qgf_NNJ$z(E9Sa09{D?!m1&*PlaH1k zS2!@=8>NbsrfwG8Y3}7vQr!(emw; zx5-vz$(i_&8Z-zUErZ_(fN9X zOjg2XK2q0+#@c19bJ}rY)s}8cQ|oVJCe@}m8lbiX-xzaE7>)7F7MLE;+e+hG&|#1F zK$*QhO#rsoQ4DMA(*4Qv@_v)u0UpAtD8mqmc$4=-0V*x__6Ic zh|U5$LNl)om?Btc+9p3xv=30gK~$diiGc}bljQK0<%J_ZJHL$DeCz2T1H_;WTn$G5 z>zDU@Cktoz1O;zx)<}?xf{p6p)713AG_mW(v{Ui{)54Flj^3en2hnd7T!_iM$czJs z=pWEVTe7CUY3)YvU>17=*}r-U5?_=pY6s6B2mKQih(!>j=qbG1App8sT9p7*twefo zcST;|3A)xw9GqDg%^?yi`3p@QZu3nV2D@(JpFZa%Oal$(2 zZg=astkL9ifa*-|-aRk(TMoi|Y({XJE}8?6TYdf7kTE?cDH^yx!n!z1(eLC`Av*4h zF4qf8Uxbp&^ggHITyGUhkqd7zA}6_+I;^S89csk*@h#cZPw~(@$z_oB(!IC zLBjBCSy`LGG)_Cf+`2Yy$UsQYX6%2jqQ2HODqmWu(Q~kQKK#3NPrt~;4zXaX3qB1k z?q=^wzutRO{<@#@Vb`X0ACsv`$I@qZk%y?{-@^sGdU|H1yfRbLo70~ac~r|BWjTkf z2afIy=DYd75h=jK_3ABw4zhwQ283U?OcgS!h3rmdm z2`G=sJUTh-KV73qka>nyBTNk#8V*i0H}zS^1B%=9PzfZH>q}45FmfcXEaVk&Ga<9Z eHmwwHae+O?e5P}A+qX;N*}rhJ40C`yLhyynJO>>B literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-metadata.png b/tests/fixtures/ingress-metadata.png new file mode 100644 index 0000000000000000000000000000000000000000..cb52c640018b75b87d4a289185677d01a91c4970 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$sS?+S66gHf+|;}hhT_z`;>?oF zvQ*vN)RM%M#FE5g8d*I+Rbn7j?g7CJIf*4!CMJ3Y3OV_CR=Q>udImfPs@?%rBzU?w zhE&XXd!CV#!I0s|hUys~JnJP>Cj>D6-B!JAx^?xeMoQozopr094vjdH?_b literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-oversized.jpg b/tests/fixtures/ingress-oversized.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5e40db747e796aa264ef3f9035bc575f28ce2d42 GIT binary patch literal 563126 zcmeIwNo-Vg9KiAaoA;*E)`iY6Wss0DQwlCIBoq{s7+LIK!I(k?fs<5lOGtpKMJ*;% zH#B;{1=L$rK)s=&=t&e=yy3!n0o*--yI#guFqn8!&Y$1Q|6SfcFaPg*pZcNtvCuNN z+Eoov6hf3T7EVL9#t;Nyhj!Jp#*z&bkOlK}xB zV`)`sRUEa2Og@V9QGHjqI6Y@JIz8dh!-nM+j?RVUH*WG>h+o5+dwl&9 zPd@YPbI)(w^umiTz5L3no40Iz?e#a_eCzG)@4UO?z4t%-XxGQP_w3#G$)}%v{>6cV zhYlY(e&WlozW(Oh@4o-x)Q>;?{L8Pu{r<!>vrXKfhTwzFy6KKaSF$7w5xuVW_j%9a+8s0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}Py_fn5FYU#?=r A!~g&Q literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-polyglot.gif b/tests/fixtures/ingress-polyglot.gif new file mode 100644 index 0000000000000000000000000000000000000000..de73330e94bd6c12290af33b469aec44e58d0c96 GIT binary patch literal 126 ycmZ?wbhEHbWMp7uVEE6V!vF+eHWP!jO>uHjWiV^^O^d_+Y literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-script.svg b/tests/fixtures/ingress-script.svg new file mode 100644 index 0000000..f157858 --- /dev/null +++ b/tests/fixtures/ingress-script.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/ingress-spoofed.html b/tests/fixtures/ingress-spoofed.html new file mode 100644 index 0000000..dd1a1df --- /dev/null +++ b/tests/fixtures/ingress-spoofed.html @@ -0,0 +1 @@ +not an image \ No newline at end of file diff --git a/tests/fixtures/ingress-truncated.jpg b/tests/fixtures/ingress-truncated.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e3eed7c7612994feb28ce8822e4dcc45c61a4343 GIT binary patch literal 581 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*Nbnf| Dqi>^Z literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-zip-polyglot.jpg b/tests/fixtures/ingress-zip-polyglot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7b7191c262c187b5f77ac21cd06a7f19146338e6 GIT binary patch literal 36 ccmex=Bfy)P<^KZ)0WUXCw*YTeHU fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url)); +const readFixture = async (name) => await readFile(fixture(name)); + +function dataUrl(bytes, mime) { + return `data:${mime};base64,${bytes.toString('base64')}`; +} + +async function ingestFixture(name, mime = 'image/jpeg', overrides = {}) { + const bytes = await readFixture(name); + return validateImageIngress({ + imageDataUrl: dataUrl(bytes, mime), + consent: true, + ...overrides, + }); +} + +test('clean synthetic JPEG passes ingress and is re-encoded without metadata', async () => { + const result = await ingestFixture('ingress-clean.jpg'); + assert.equal(result.format, 'jpeg'); + assert.ok(result.bytes <= MAX_IMAGE_BYTES); + assert.match(result.imageDataUrl, /^data:image\/jpeg;base64,/); + const exifCount = execFileSync('python3', [ + '-c', + 'from PIL import Image;import sys,base64;print(len(Image.open(__import__("io").BytesIO(base64.b64decode(sys.argv[1]))).getexif()))', + result.imageDataUrl.split(',')[1], + ], { encoding: 'utf8' }); + assert.equal(exifCount.trim(), '0'); +}); + +test('magic bytes are verified independent of declared MIME', async () => { + // HTML payload wearing a JPEG content type must be rejected. + await assert.rejects( + () => ingestFixture('ingress-spoofed.html'), + /not a supported image/i, + ); + // A real PNG declared as JPEG must still be accepted by sniffing, not by MIME. + const png = await ingestFixture('ingress-metadata.png', 'image/jpeg'); + assert.equal(png.originalFormat, 'png'); + assert.equal(png.format, 'jpeg'); +}); + +test('polyglot payloads are rejected', async () => { + await assert.rejects(() => ingestFixture('ingress-polyglot.gif'), /not a supported image|rejected|unsafe/i); + await assert.rejects(() => ingestFixture('ingress-zip-polyglot.jpg'), /rejected|unsafe|corrupt|malformed|not a supported image/i); +}); + +test('malformed and truncated images fail closed with sanitized errors', async () => { + for (const [name, mime] of [['ingress-garbage.jpg', 'image/jpeg'], ['ingress-empty.jpg', 'image/jpeg']]) { + await assert.rejects(() => ingestFixture(name, mime), (error) => { + assert.match(error.message, /upload a jpeg, png, or webp photo/i); + return true; + }); + } + await assert.rejects(() => ingestFixture('ingress-truncated.jpg'), /corrupt or malformed/i); +}); + +test('decompression bombs and oversized dimensions fail before provider work', async () => { + await assert.rejects(() => ingestFixture('ingress-bomb.png'), /too large|dimensions/i); + await assert.rejects(() => ingestFixture('ingress-oversized.jpg'), /too large|dimensions/i); + assert.equal(MAX_IMAGE_DIMENSION <= 4096, true); +}); + +test('EXIF and GPS metadata are stripped from the re-encoded image', async () => { + const result = await ingestFixture('ingress-exif.jpg'); + assert.equal(result.metadataStripped, true); + const check = execFileSync('python3', ['-c', + 'from PIL import Image;import sys,base64,io;' + + 'im=Image.open(io.BytesIO(base64.b64decode(sys.argv[1])));' + + 'ex=im.getexif();' + + 'gps=ex.get_ifd(0x8825);' + + 'print("MAKE" if ex.get(0x010F) else "CLEAN", "GPS" if gps else "CLEAN")', + result.imageDataUrl.split(',')[1], + ], { encoding: 'utf8' }); + assert.equal(check.trim(), 'CLEAN CLEAN'); + const pngResult = await ingestFixture('ingress-metadata.png'); + const pngCheck = execFileSync('python3', ['-c', + 'from PIL import Image;import sys,base64,io;' + + 'im=Image.open(io.BytesIO(base64.b64decode(sys.argv[1])));' + + 'info=getattr(im,"text",{}) or {};' + + 'print("TEXT" if info else "CLEAN", im.format.lower())', + pngResult.imageDataUrl.split(',')[1], + ], { encoding: 'utf8' }); + assert.equal(pngCheck.trim(), 'CLEAN jpeg'); +}); + +test('body limit rejects oversized base64 bodies before decoding', async () => { + const huge = Buffer.alloc(MAX_IMAGE_BYTES + 1024, 65); + await assert.rejects( + () => validateImageIngress({ imageDataUrl: dataUrl(huge, 'image/jpeg'), consent: true }), + /under \d+ mb/i, + ); +}); + +test('consent is still required after hardening', async () => { + const bytes = await readFixture('ingress-clean.jpg'); + await assert.rejects( + () => validateImageIngress({ imageDataUrl: dataUrl(bytes, 'image/jpeg') }), + /consent/i, + ); +}); + +test('SVG uploads never pass ingress regardless of extension', async () => { + await assert.rejects( + () => ingestFixture('ingress-script.svg', 'image/svg+xml'), + /not a supported image/i, + ); +}); + +test('errors are sanitized: no image bytes, no base64, no stack in messages', async () => { + const cases = []; + for (const name of ['ingress-spoofed.html', 'ingress-polyglot.gif', 'ingress-truncated.jpg', 'ingress-bomb.png']) { + try { await ingestFixture(name); } catch (error) { cases.push(error.message); } + } + for (const message of cases) { + assert.doesNotMatch(message, /[A-Za-z0-9+/]{40,}/); + assert.doesNotMatch(message, /at\s+\S+\s+\(/); + assert.ok(message.length < 200); + } +}); + +test('rate limiter allows a bounded burst then fails closed with sanitized retry message', async () => { + const { createRateLimiter } = await import('../src/rate-limiter.js'); + const limiter = createRateLimiter(); + const first = limiter.take('client-a', Date.now()); + assert.equal(first.allowed, true); + for (let i = 0; i < limiter.limit - 1; i += 1) { + assert.equal(limiter.take('client-a', Date.now()).allowed, true); + } + const blocked = limiter.take('client-a', Date.now()); + assert.equal(blocked.allowed, false); + assert.match(blocked.reason, /try again later|slow down/i); + assert.ok(blocked.retryAfterMs > 0); + assert.doesNotMatch(blocked.reason, /image|payload|byte/i); + // A different client key is unaffected. + assert.equal(limiter.take('client-b', Date.now()).allowed, true); +}); + +test('analyzePhoto routes through hardened ingress before provider fetch', async () => { + const bytes = await readFixture('ingress-polyglot.gif'); + let providerCalled = false; + await assert.rejects( + () => analyzePhoto({ + payload: { consent: true, imageDataUrl: dataUrl(bytes, 'image/gif') }, + config: { model: 'm', baseUrl: 'http://127.0.0.1:9/v1' }, + fetchImpl: async () => { providerCalled = true; throw new Error('provider reached'); }, + }), + /not a supported image|rejected|unsafe/i, + ); + assert.equal(providerCalled, false); +}); diff --git a/tests/rate-limiter.test.js b/tests/rate-limiter.test.js new file mode 100644 index 0000000..e72a7b8 --- /dev/null +++ b/tests/rate-limiter.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createRateLimiter } from '../src/rate-limiter.js'; + +test('rate limiter blocks after the configured burst and recovers next window', () => { + const limiter = createRateLimiter({ windowMs: 1000, maxRequests: 3 }); + const t0 = 1_000_000; + assert.equal(limiter.take('k', t0).allowed, true); + assert.equal(limiter.take('k', t0 + 1).allowed, true); + assert.equal(limiter.take('k', t0 + 2).allowed, true); + const blocked = limiter.take('k', t0 + 3); + assert.equal(blocked.allowed, false); + assert.ok(blocked.retryAfterMs > 0 && blocked.retryAfterMs <= 1000); + // Next window resets cleanly. + assert.equal(limiter.take('k', t0 + 1001).allowed, true); +}); + +test('rate limiter keys are isolated and never expose payload data', () => { + const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 1 }); + assert.equal(limiter.take('a').allowed, true); + assert.equal(limiter.take('b').allowed, true); + const blocked = limiter.take('a'); + assert.equal(blocked.allowed, false); + assert.doesNotMatch(blocked.reason, /image|base64|byte/i); +}); diff --git a/tests/vision-service.test.js b/tests/vision-service.test.js index df19d6e..27924e2 100644 --- a/tests/vision-service.test.js +++ b/tests/vision-service.test.js @@ -1,7 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + import { analyzePhoto } from '../src/vision-service.js'; +const cleanJpeg = await readFile(fileURLToPath(new URL('./fixtures/ingress-clean.jpg', import.meta.url))); +const imageDataUrl = `data:image/jpeg;base64,${cleanJpeg.toString('base64')}`; + test('sends a bounded structured request to the configured provider and validates its response', async () => { let captured; const fetchImpl = async (url, options) => { @@ -15,7 +21,7 @@ test('sends a bounded structured request to the configured provider and validate }; }; const result = await analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true }, + payload: { imageDataUrl, consent: true }, fetchImpl, config: { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'secret', model: 'vision-model' }, }); @@ -28,21 +34,21 @@ test('sends a bounded structured request to the configured provider and validate test('fails closed when the provider is unavailable or malformed', async () => { await assert.rejects(() => analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true }, + payload: { imageDataUrl, consent: true }, fetchImpl: async () => ({ ok: false, status: 503, text: async () => 'upstream detail' }), - config: { baseUrl: 'http://localhost/v1', apiKey: 'x', model: 'm' }, + config: { baseUrl: 'http://localhost/v1', apiKey: 'secret', model: 'm' }, }), /temporarily unavailable/i); await assert.rejects(() => analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true }, + payload: { imageDataUrl, consent: true }, fetchImpl: async () => ({ ok: true, json: async () => ({ choices: [] }) }), - config: { baseUrl: 'http://localhost/v1', apiKey: 'x', model: 'm' }, + config: { baseUrl: 'http://localhost/v1', apiKey: 'secret', model: 'm' }, }), /invalid/i); }); test('requires an http(s) provider URL and never accepts credentials from the browser payload', async () => { await assert.rejects(() => analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true, apiKey: 'browser-secret' }, + payload: { imageDataUrl, consent: true, apiKey: 'injected' }, fetchImpl: async () => { throw new Error('must not call'); }, - config: { baseUrl: 'file:///tmp/provider', apiKey: 'server-secret', model: 'm' }, + config: { baseUrl: 'file:///tmp/provider', apiKey: 'secret', model: 'm' }, }), /provider URL/i); }); -- 2.43.0 From 517c8dbac3229377acadc948996ea731efd31ee3 Mon Sep 17 00:00:00 2001 From: Timmy Date: Sat, 22 Aug 2026 23:22:42 +0000 Subject: [PATCH 2/4] Harden image ingress: pinned runtime, header-bomb rejection, concurrency ceiling, bounded rate limiter, canonical data-URL/polyglot contract, 503-on-unavailable, body-read timeout 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). --- .gitea/workflows/quality.yml | 3 + package.json | 4 +- scripts/build_release.py | 9 +- scripts/deploy_staging.py | 56 ++++ scripts/gen_ingress_fixtures.py | 50 ++++ scripts/reencode_image.py | 152 ++++++++--- server.mjs | 103 ++++++- src/client-identity.js | 70 +++++ src/image-container.js | 141 ++++++++++ src/image-ingress.js | 267 ++++++++++++++++--- src/rate-limiter.js | 116 ++++++-- tests/body-timeout.test.js | 132 +++++++++ tests/fixtures/ingress-buried-signatures.jpg | Bin 0 -> 1206 bytes tests/fixtures/ingress-entropy-control.jpg | Bin 0 -> 30651 bytes tests/fixtures/ingress-garbage.jpg | Bin 2048 -> 2048 bytes tests/fixtures/ingress-tail-7z.jpg | Bin 0 -> 1168 bytes tests/fixtures/ingress-tail-after-iend.png | Bin 0 -> 250 bytes tests/fixtures/ingress-tail-gzip.jpg | Bin 0 -> 1172 bytes tests/fixtures/ingress-tail-html.jpg | Bin 0 -> 1216 bytes tests/fixtures/ingress-tail-mixed-script.jpg | Bin 0 -> 1187 bytes tests/fixtures/ingress-tail-rar.jpg | Bin 0 -> 1169 bytes tests/fixtures/ingress-tail-single-nul.jpg | Bin 0 -> 1163 bytes tests/fixtures/ingress-tail-upper-script.jpg | Bin 0 -> 1187 bytes tests/fixtures/ingress-tail-zip-eocd.jpg | Bin 0 -> 1184 bytes tests/fixtures/ingress-tail-zip-local.jpg | Bin 0 -> 1192 bytes tests/image-concurrency.test.js | 146 ++++++++++ tests/image-dataurl.test.js | 95 +++++++ tests/image-ingress.test.js | 8 +- tests/image-polyglot.test.js | 86 ++++++ tests/image-unavailable.test.js | 154 +++++++++++ tests/rate-identity.test.js | 135 ++++++++++ tests/rate-limiter.test.js | 11 +- tests/reencode-image.test.py | 185 +++++++++++++ tests/staging-deploy.test.py | 24 +- tests/staging-health.test.js | 2 +- 35 files changed, 1844 insertions(+), 105 deletions(-) create mode 100644 src/client-identity.js create mode 100644 src/image-container.js create mode 100644 tests/body-timeout.test.js create mode 100644 tests/fixtures/ingress-buried-signatures.jpg create mode 100644 tests/fixtures/ingress-entropy-control.jpg create mode 100644 tests/fixtures/ingress-tail-7z.jpg create mode 100644 tests/fixtures/ingress-tail-after-iend.png create mode 100644 tests/fixtures/ingress-tail-gzip.jpg create mode 100644 tests/fixtures/ingress-tail-html.jpg create mode 100644 tests/fixtures/ingress-tail-mixed-script.jpg create mode 100644 tests/fixtures/ingress-tail-rar.jpg create mode 100644 tests/fixtures/ingress-tail-single-nul.jpg create mode 100644 tests/fixtures/ingress-tail-upper-script.jpg create mode 100644 tests/fixtures/ingress-tail-zip-eocd.jpg create mode 100644 tests/fixtures/ingress-tail-zip-local.jpg create mode 100644 tests/image-concurrency.test.js create mode 100644 tests/image-dataurl.test.js create mode 100644 tests/image-polyglot.test.js create mode 100644 tests/image-unavailable.test.js create mode 100644 tests/rate-identity.test.js create mode 100644 tests/reencode-image.test.py diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index c77cc35..1a9863d 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -33,6 +33,7 @@ jobs: 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 +58,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; 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(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 ae71401..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/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", + "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/image-ingress.js && node --check src/rate-limiter.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 scripts/reencode_image.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 index a88d798..2147977 100644 --- a/scripts/gen_ingress_fixtures.py +++ b/scripts/gen_ingress_fixtures.py @@ -77,4 +77,54 @@ with open(os.path.join(D, "ingress-script.svg"), "wb") as f: f.write(b'' 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"alert(1)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 index 76a3592..b1bc59e 100644 --- a/scripts/reencode_image.py +++ b/scripts/reencode_image.py @@ -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__": diff --git a/server.mjs b/server.mjs index ca1ec59..46cd33c 100644 --- a/server.mjs +++ b/server.mjs @@ -5,10 +5,17 @@ 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); @@ -35,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); @@ -56,15 +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 clientKey=(req.socket&&req.socket.remoteAddress)||'unknown'; - const limit=analyzeRateLimiter.take(clientKey); + 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.'}); } - 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|supported image|corrupt|malformed|slow down/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})} + 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 `= bytes.length) return null; + if (bytes[offset] !== 0xff) return null; + // Fill bytes: any number of 0xFF may precede a marker code. + let marker = bytes[offset + 1]; + let markerAt = offset + 1; + while (marker === 0xff) { + markerAt += 1; + if (markerAt >= bytes.length) return null; + marker = bytes[markerAt]; + } + if (marker === 0x00) return null; // stuffed byte outside entropy data + if (marker === 0xd9) return { format: 'jpeg', consumed: markerAt + 1 }; // EOI + if (JPEG_STANDALONE.has(marker)) { + offset = markerAt + 1; + continue; + } + if (markerAt + 2 >= bytes.length) return null; + const length = bytes.readUInt16BE(markerAt + 1); + if (length < 2) return null; + const segmentEnd = markerAt + 1 + length; + if (segmentEnd > bytes.length) return null; + if (marker !== 0xda) { + offset = segmentEnd; + continue; + } + // Start of scan: entropy-coded data runs until the next real marker. + let scan = segmentEnd; + for (; scan < bytes.length; scan += 1) { + if (bytes[scan] !== 0xff) continue; + const next = bytes[scan + 1]; + if (next === undefined) return null; + if (next === 0x00) { scan += 1; continue; } // stuffed 0xFF data byte + if (next >= 0xd0 && next <= 0xd7) { scan += 1; continue; } // restart marker + if (next === 0xff) continue; // fill byte + break; + } + if (scan >= bytes.length) return null; // ran out of data before EOI + offset = scan; + } + return null; +} + +function parsePng(bytes) { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (bytes.length < 8 || !bytes.subarray(0, 8).equals(signature)) return null; + let offset = 8; + let seenHeader = false; + for (let step = 0; step < MAX_STRUCTURE_STEPS; step += 1) { + if (offset + 8 > bytes.length) return null; + const length = bytes.readUInt32BE(offset); + if (length > 0x7fffffff) return null; + const type = bytes.subarray(offset + 4, offset + 8).toString('latin1'); + if (!/^[A-Za-z]{4}$/.test(type)) return null; + if (!seenHeader && type !== 'IHDR') return null; + seenHeader = true; + const end = offset + 8 + length + 4; // data + CRC + if (end > bytes.length) return null; + if (type === 'IEND') { + if (length !== 0) return null; + return { format: 'png', consumed: end }; + } + offset = end; + } + return null; +} + +function parseWebp(bytes) { + if (bytes.length < 12) return null; + if (bytes.subarray(0, 4).toString('latin1') !== 'RIFF') return null; + if (bytes.subarray(8, 12).toString('latin1') !== 'WEBP') return null; + const riffSize = bytes.readUInt32LE(4); + if (riffSize < 4 || riffSize > 0x7fffffff) return null; + const declaredEnd = 8 + riffSize; + if (declaredEnd > bytes.length) return null; + // Walk the chunk list so a truncated or over-declared RIFF is caught too. + let offset = 12; + for (let step = 0; step < MAX_STRUCTURE_STEPS; step += 1) { + if (offset === declaredEnd) return { format: 'webp', consumed: declaredEnd }; + if (offset + 8 > declaredEnd) return null; + const type = bytes.subarray(offset, offset + 4).toString('latin1'); + if (!/^[A-Za-z0-9 ]{4}$/.test(type)) return null; + const size = bytes.readUInt32LE(offset + 4); + if (size > 0x7fffffff) return null; + const padded = size + (size % 2); + const end = offset + 8 + padded; + if (end > declaredEnd) return null; + offset = end; + } + return null; +} + +const PARSERS = [parseJpeg, parsePng, parseWebp]; + +/** + * Parse a supported raster container structurally. + * + * Returns `{ format, consumed, trailingBytes }` when the signature matches a + * supported format and the structure is internally consistent, otherwise null. + * `trailingBytes` is how many supplied bytes the container did not account for. + */ +export function parseImageContainer(bytes) { + if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null; + for (const parse of PARSERS) { + const parsed = parse(bytes); + if (parsed) { + return { ...parsed, trailingBytes: bytes.length - parsed.consumed }; + } + } + return null; +} + +/** + * Canonical acceptance: a supported container whose own structure accounts for + * every supplied byte. Any trailing data is a container polyglot and fails. + */ +export function canonicalImageFormat(bytes) { + const parsed = parseImageContainer(bytes); + if (!parsed || parsed.trailingBytes !== 0) return null; + return parsed.format; +} diff --git a/src/image-ingress.js b/src/image-ingress.js index 5b8e879..c720975 100644 --- a/src/image-ingress.js +++ b/src/image-ingress.js @@ -3,37 +3,128 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { canonicalImageFormat } from './image-container.js'; export const MAX_IMAGE_BYTES = 4 * 1024 * 1024; export const MAX_IMAGE_DIMENSION = 4096; +// Total-pixel ceiling, bounded independently of the per-side cap so extreme +// aspect ratios cannot smuggle a large pixel budget past a per-dimension check. +export const MAX_IMAGE_PIXELS = MAX_IMAGE_DIMENSION * MAX_IMAGE_DIMENSION; const REENCODE_TIMEOUT_MS = 15_000; +// Production runs a pinned, immutable interpreter provisioned outside the +// release tree; TIMMY_PYTHON must be an absolute path so the service never +// depends on PATH resolution. Development falls back to `python3`. +export function resolveInterpreter(env = process.env) { + const configured = String(env.TIMMY_PYTHON || '').trim(); + if (!configured) return 'python3'; + if (!configured.startsWith('/')) { + throw new Error('TIMMY_PYTHON must be an absolute interpreter path.'); + } + return configured; +} + +// The pinned, immutable production image runtime. The service refuses to start +// decode work if the provisioned interpreter is not this exact toolchain: +// a drifting runtime is a server-side provisioning fault, and decoding images +// under an untested Pillow/Pillow-version would silently change the security +// boundary. The deployment smoke gate (scripts/deploy_staging.py) verifies the +// same pin before promoting; this is the in-process fail-closed backstop. +export const PINNED_PYTHON_VERSION = '3.11'; +export const PINNED_PILLOW_VERSION = '12.3.0'; +export const REENCODE_SMOKE_TIMEOUT_MS = 10_000; + +/** + * Verify the provisioned re-encoder runtime matches the pinned, immutable + * production toolchain. Runs the re-encoder with --verify-pin and maps any + * non-zero exit (or a version mismatch) to an IngressUnavailableError so the + * transport layer answers 503 rather than blaming a client photo. + */ +export function verifyReencodeRuntime({ timeoutMs = REENCODE_SMOKE_TIMEOUT_MS } = {}) { + return new Promise((resolve, reject) => { + execFile( + resolveInterpreter(), + [ + join(fileURLToPath(new URL('..', import.meta.url)), 'scripts', 'reencode_image.py'), + '--verify-pin', + ], + { timeout: timeoutMs, windowsHide: true, maxBuffer: 64 * 1024 }, + (error, stdout) => { + if (error) { + // Missing, non-runnable, or version-mismatched runtime: a provisioning + // fault, not the client's problem. + return reject(sanitizedError('unavailable')); + } + let verdict = {}; + try { verdict = JSON.parse(String(stdout).trim()); } catch { /* fall through */ } + if (verdict.ok !== true) { + return reject(sanitizedError('unavailable')); + } + return resolve({ + python: verdict.python, + pillow: verdict.pillow, + pinned: verdict.pinned, + }); + }, + ); + }); +} + const MAGIC = { jpeg: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, png: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47, webp: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP', }; -function base64Bytes(dataUrl) { - const match = /^data:[^;,]*;base64,([A-Za-z0-9+/=\r\n]+)$/.exec(String(dataUrl || '')); - if (!match) return null; - const clean = match[1].replace(/[\r\n]/g, ''); - return Buffer.from(clean, 'base64'); -} - export function sniffImageFormat(bytes) { if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null; for (const [format, test] of Object.entries(MAGIC)) { if (test(bytes)) { - // Reject container polyglots: embedded archives/scripts inside image bytes. - if (bytes.includes(Buffer.from('PK\x03\x04'))) return null; - if (bytes.includes(Buffer.from('= INGRESS_MAX_CONCURRENCY) return false; + activeDecodes += 1; + return true; +} + +function releaseDecodeSlot() { + activeDecodes = Math.max(0, activeDecodes - 1); +} + function sanitizedError(key) { + if (key === 'unavailable' || key === 'capacity') return new IngressUnavailableError(SANITIZED[key]); return new Error(SANITIZED[key]); } -function reencode(sourcePath, targetPath) { +// Exit-code contract shared with scripts/reencode_image.py: +// 0 success, 2 rejected hostile/invalid input, 3 processing unavailable. +const REENCODE_EXIT_REJECTED = 2; +const REENCODE_EXIT_UNAVAILABLE = 3; + +function reencode(sourcePath, targetPath, { timeoutMs = REENCODE_TIMEOUT_MS } = {}) { return new Promise((resolve, reject) => { execFile( - process.env.TIMMY_PYTHON || 'python3', + resolveInterpreter(), [ join(fileURLToPath(new URL('..', import.meta.url)), 'scripts', 'reencode_image.py'), '--in', sourcePath, '--out', targetPath, '--max-bytes', String(MAX_IMAGE_BYTES), '--max-dimension', String(MAX_IMAGE_DIMENSION), + '--max-pixels', String(MAX_IMAGE_PIXELS), ], - { timeout: REENCODE_TIMEOUT_MS, windowsHide: true }, + { timeout: timeoutMs, windowsHide: true }, (error, stdout) => { if (error) { - if (error.killed || error.signal === 'SIGTERM') return reject(sanitizedError('unavailable')); - // Exit code 2 with a dimensions verdict maps to a distinct message. - if (/dimensions/.test(String(stdout))) return reject(sanitizedError('dimensions')); - return reject(sanitizedError('corrupt')); + // Timeout or signal kill: the runtime did not finish, so this is a + // server-side fault rather than bad client input. + if (error.killed || error.signal) return reject(sanitizedError('unavailable')); + // The interpreter itself could not be executed (missing/not runnable). + if (error.code === 'ENOENT' || error.code === 'EACCES') { + return reject(sanitizedError('unavailable')); + } + let verdict = {}; + try { + verdict = JSON.parse(String(stdout).trim().split('\n').pop() || '{}'); + } catch { verdict = {}; } + // Exit 3 means the image runtime is unavailable. Reporting this as a + // client 400 would falsely blame the user's photo. + if (error.code === REENCODE_EXIT_UNAVAILABLE || verdict.error === 'unavailable') { + return reject(sanitizedError('unavailable')); + } + if (error.code === REENCODE_EXIT_REJECTED) { + if (verdict.error === 'dimensions') return reject(sanitizedError('dimensions')); + return reject(sanitizedError('corrupt')); + } + // Any other exit status is an unexpected runtime failure, not a + // proven statement about the client's input. + return reject(sanitizedError('unavailable')); } try { resolve(JSON.parse(String(stdout).trim())); } catch { - reject(sanitizedError('corrupt')); + reject(sanitizedError('unavailable')); } }, ); @@ -78,37 +244,56 @@ function reencode(sourcePath, targetPath) { } export async function validateImageIngress(payload = {}) { + // Cheap, allocation-free checks run before any capacity is taken so hostile + // input can never occupy a decoder slot. if (payload.consent !== true) throw sanitizedError('consent'); - const bytes = base64Bytes(payload.imageDataUrl); - if (!bytes || bytes.length === 0) throw sanitizedError('format'); + const decoded = decodeImageDataUrl(payload.imageDataUrl); + if (!decoded) throw sanitizedError('format'); + const bytes = decoded.bytes; if (bytes.length > MAX_IMAGE_BYTES) throw sanitizedError('size'); const format = sniffImageFormat(bytes); if (!format) throw sanitizedError('format'); - const workDir = await mkdtemp(join(tmpdir(), 'timmy-ingress-')); + // Expensive work starts here: bound it with a fail-fast ceiling so a burst + // cannot spawn enough decoders to exceed the service memory budget. + if (!acquireDecodeSlot()) throw sanitizedError('capacity'); try { - const sourcePath = join(workDir, `source.${format}`); - const targetPath = join(workDir, 'reencoded.jpg'); - await writeFile(sourcePath, bytes); - let result; + const workDir = await mkdtemp(join(tmpdir(), 'timmy-ingress-')); try { - result = await reencode(sourcePath, targetPath); - } catch (error) { - throw error; + const sourcePath = join(workDir, `source.${format}`); + const targetPath = join(workDir, 'reencoded.jpg'); + await writeFile(sourcePath, bytes); + const result = await reencode(sourcePath, targetPath, { + timeoutMs: Number.isFinite(payload.reencodeTimeoutMs) && payload.reencodeTimeoutMs > 0 + ? payload.reencodeTimeoutMs + : REENCODE_TIMEOUT_MS, + }); + let reencoded; + try { + reencoded = await readFile(targetPath); + } catch { + // The subprocess reported success but produced no readable output: a + // server-side fault, not a statement about the client's photo. + throw sanitizedError('unavailable'); + } + return { + imageDataUrl: `data:image/jpeg;base64,${reencoded.toString('base64')}`, + mime: 'image/jpeg', + format: result.format, + width: result.width, + height: result.height, + bytes: reencoded.length, + originalFormat: format, + metadataStripped: result.metadataStripped === true, + }; + } finally { + // Private temp directory cleanup stays unconditional. + await rm(workDir, { recursive: true, force: true }).catch(() => {}); } - const reencoded = await readFile(targetPath); - return { - imageDataUrl: `data:image/jpeg;base64,${reencoded.toString('base64')}`, - mime: 'image/jpeg', - format: result.format, - width: result.width, - height: result.height, - bytes: reencoded.length, - originalFormat: format, - metadataStripped: result.metadataStripped === true, - }; } finally { - await rm(workDir, { recursive: true, force: true }).catch(() => {}); + // The slot is released on every path, including rejections, so a hostile + // burst cannot permanently exhaust capacity. + releaseDecodeSlot(); } } diff --git a/src/rate-limiter.js b/src/rate-limiter.js index d56a01e..92372cd 100644 --- a/src/rate-limiter.js +++ b/src/rate-limiter.js @@ -1,31 +1,117 @@ -// Fixed-window in-memory rate limiter for image analysis requests. -// Bounded: per-key counters only, no payloads stored. +// Bounded rate limiter for image analysis requests. +// +// Two properties the naive fixed-window version did not have: +// +// 1. Hard-bounded state. Sweeping only *expired* entries lets an attacker with +// many distinct keys grow the map without limit inside one window. State is +// capped at maxKeys; reclamation prefers expired entries, and when none are +// expired the entry closest to expiry is dropped. Currently-blocked +// offenders are never dropped, so flooding cannot reset someone's counter. +// +// 2. No boundary doubling. A fixed window lets a client spend a full budget at +// the end of one window and another immediately after the boundary, i.e. +// 2x the nominal rate in milliseconds. This uses a two-window weighted +// sliding count so the boundary is smooth. +// +// Only counters are stored — never payloads, bodies, or headers. -export function createRateLimiter({ windowMs = 60_000, maxRequests = 10 } = {}) { +const DEFAULT_MAX_KEYS = 10_000; + +export function createRateLimiter({ + windowMs = 60_000, + maxRequests = 10, + maxKeys = DEFAULT_MAX_KEYS, +} = {}) { + // key -> { windowStart, count, previousCount } const windows = new Map(); + function slidingCount(entry, now) { + // Weight the previous window by how much of it still overlaps the trailing + // `windowMs` interval ending at `now`. + const elapsed = now - entry.windowStart; + const overlap = Math.max(0, 1 - elapsed / windowMs); + return entry.previousCount * overlap + entry.count; + } + + function isBlocked(entry, now) { + return slidingCount(entry, now) >= maxRequests; + } + + function reclaim(now, protectedKey) { + if (windows.size < maxKeys) return; + // First pass: drop fully expired entries (two windows old, so their + // weighted contribution is zero). + for (const [key, entry] of windows) { + if (key === protectedKey) continue; + if (now - entry.windowStart >= windowMs * 2) windows.delete(key); + } + if (windows.size < maxKeys) return; + // Second pass: hard bound. Drop the oldest entries that are not currently + // blocking anyone, so eviction can never reset an active offender's count. + const candidates = []; + for (const [key, entry] of windows) { + if (key === protectedKey) continue; + if (isBlocked(entry, now)) continue; + candidates.push([key, entry.windowStart]); + } + candidates.sort((a, b) => a[1] - b[1]); + for (const [key] of candidates) { + if (windows.size < maxKeys) break; + windows.delete(key); + } + if (windows.size < maxKeys) return; + // Every retained entry is actively blocked. Drop the oldest of those to + // preserve the hard bound; its window is closest to expiring anyway. + let oldestKey = null; + let oldestStart = Infinity; + for (const [key, entry] of windows) { + if (key === protectedKey) continue; + if (entry.windowStart < oldestStart) { + oldestStart = entry.windowStart; + oldestKey = key; + } + } + if (oldestKey !== null) windows.delete(oldestKey); + } + function take(key, now = Date.now()) { let entry = windows.get(key); - if (!entry || now >= entry.resetAt) { - entry = { count: 0, resetAt: now + windowMs }; + if (!entry) { + reclaim(now, key); + entry = { windowStart: now, count: 0, previousCount: 0 }; windows.set(key, entry); - } - entry.count += 1; - if (windows.size > 10_000) { - // Bound memory: drop expired entries when the map grows too large. - for (const [existingKey, existing] of windows) { - if (now >= existing.resetAt) windows.delete(existingKey); + } else { + const elapsed = now - entry.windowStart; + if (elapsed >= windowMs * 2) { + entry.windowStart = now; + entry.count = 0; + entry.previousCount = 0; + } else if (elapsed >= windowMs) { + entry.windowStart = entry.windowStart + windowMs * Math.floor(elapsed / windowMs); + entry.previousCount = entry.count; + entry.count = 0; } } - if (entry.count > maxRequests) { + + if (slidingCount(entry, now) + 1 > maxRequests) { + // Retry after the remaining overlap of the trailing interval. + const elapsed = now - entry.windowStart; + const retryAfterMs = Math.max(1, Math.ceil(windowMs - elapsed)); return { allowed: false, - retryAfterMs: Math.max(1, entry.resetAt - now), + retryAfterMs, reason: 'Too many photo analyses. Please slow down and try again later.', }; } - return { allowed: true, remaining: maxRequests - entry.count }; + entry.count += 1; + return { allowed: true, remaining: Math.max(0, maxRequests - Math.ceil(slidingCount(entry, now))) }; } - return { take, limit: maxRequests, windowMs }; + return { + take, + limit: maxRequests, + windowMs, + maxKeys, + size: () => windows.size, + }; } diff --git a/tests/body-timeout.test.js b/tests/body-timeout.test.js new file mode 100644 index 0000000..93bf89a --- /dev/null +++ b/tests/body-timeout.test.js @@ -0,0 +1,132 @@ +// Inbound body-read timeout and stop-on-oversize contract. +// +// A client must not be able to hold a connection open by dribbling a body +// forever, nor make the server drain an enormous payload before rejecting it. +// The server must enforce a body-read timeout and stop/destroy oversized request +// processing as soon as the declared or observed size exceeds the limit, while +// preserving the configured base path and a sanitized 413. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as sleep } from 'node:timers/promises'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const BASE_PORT = 4199; +const originFor = (port) => `http://127.0.0.1:${port}`; +const BODY_TIMEOUT_MS = 1500; + +let nextPort = BASE_PORT; +let currentPort = BASE_PORT; +async function startServer(env = {}) { + const PORT = nextPort++; + currentPort = PORT; + const workdir = await mkdtemp(join(tmpdir(), 'timmy-body-test-')); + const child = spawn(process.execPath, ['server.mjs'], { + cwd: root, + env: { + ...process.env, + PORT: String(PORT), + TIMMY_VISION_ENABLED: 'true', + TIMMY_BODY_READ_TIMEOUT_MS: String(BODY_TIMEOUT_MS), + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const origin = originFor(PORT); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}`); + try { const response = await fetch(`${origin}/api/healthz`); if (response.ok) break; } catch {} + await sleep(50); + } + return { child, workdir, origin }; +} + +test('a slow-dribbling request body is terminated by the body-read timeout', async () => { + const { child, workdir, origin } = await startServer(); + try { + const socket = net.connect(currentPort, '127.0.0.1'); + await new Promise((resolve) => socket.once('connect', resolve)); + // Declare a large body and then dribble spaces far slower than the timeout. + socket.write( + `POST ${basePath()}/api/analyze HTTP/1.1\r\n` + + `Host: 127.0.0.1\r\nContent-Type: application/json\r\n` + + `Content-Length: ${8 * 1024 * 1024}\r\nConnection: close\r\n\r\n`, + ); + const start = Date.now(); + let written = 0; + const chunk = Buffer.alloc(64 * 1024, 0x20); + let ended = false; + let closeAt = 0; + const writer = setInterval(() => { + if (written >= 7 * 1024 * 1024 || socket.destroyed) { clearInterval(writer); return; } + try { socket.write(chunk); written += chunk.length; } catch { clearInterval(writer); } + }, 400); + socket.on('close', () => { ended = true; closeAt = Date.now(); }); + // Swallow EPIPE: once the server tears down the socket, any in-flight write + // must not crash the test process. + socket.on('error', () => { clearInterval(writer); }); + // Wait well past the timeout to see whether the server kills the slow stream. + await sleep(BODY_TIMEOUT_MS + 2500); + clearInterval(writer); + const elapsed = Date.now() - start; + socket.destroy(); + assert.ok(ended, 'the server must close a slow-dribbling body before it finishes streaming'); + assert.ok(closeAt - start < BODY_TIMEOUT_MS + 2000, + `a slow body was allowed to stream for ${closeAt - start}ms; the timeout is not enforced`); + } finally { + child.kill('SIGTERM'); + await rm(workdir, { recursive: true, force: true }); + } +}); + +test('an oversized request is rejected with a sanitized 413 and not drained', async () => { + const { child, workdir, origin } = await startServer(); + try { + const response = await fetch(`${origin}${basePath()}/api/analyze`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // No Content-Length: the server must stop once the stream exceeds the cap, + // not wait for the client to finish sending. `duplex: 'half'` is required + // by fetch when a request body is a streaming ReadableStream. + duplex: 'half', + body: new ReadableStream({ + start(controller) { + const blob = new Uint8Array(10 * 1024 * 1024).fill(0x20); + controller.enqueue(blob); + // Keep the stream open so the server must stop it, not us. + setTimeout(() => controller.close(), 5000); + }, + }), + }); + assert.equal(response.status, 413); + const body = await response.text(); + assert.match(body, /too large/i); + assert.doesNotMatch(body, /[A-Za-z0-9+/]{40,}/, 'no payload data in the 413 response'); + } finally { + child.kill('SIGTERM'); + await rm(workdir, { recursive: true, force: true }); + } +}); + +test('the configured base path is preserved on the analyze route after hardening', async () => { + const { child, workdir, origin } = await startServer({ TIMMY_BASE_PATH: '/timmy-staging' }); + try { + const response = await fetch(`${origin}/timmy-staging/api/healthz`); + assert.equal(response.status, 200); + const missing = await fetch(`${origin}/api/healthz`); + assert.equal(missing.status, 404); + } finally { + child.kill('SIGTERM'); + await rm(workdir, { recursive: true, force: true }); + } +}); + +function basePath() { + return process.env.TIMMY_BASE_PATH_TEST || ''; +} diff --git a/tests/fixtures/ingress-buried-signatures.jpg b/tests/fixtures/ingress-buried-signatures.jpg new file mode 100644 index 0000000000000000000000000000000000000000..62e6965a0e396649c45b73c2b6b14491cb2103cc GIT binary patch literal 1206 zcmex=3G#7s z3y28_3X6z}it_M_ONfa`2#JV_fDB<|2I^s98aRU`>6<}auWM*b!VFtMxsJa#?&%h$c zDx_%W$R-?^$gWfnAuRebI z{N?Mn?>~P20{M%Pff?d0APMmpn!f~r{$gTbVP;_m`HPXM9Eh0(Sy&Yf*@PSe*%J$e zm5drWM4Tor+<1^v**NHfXj0KdE-@98hpHbzUIY7#IFB`v@=X_PuC!>sM=2GE<<(x^97O+6a{TnVki)>3VClcf(;k1j^79wi>Z{8pUw*o_ z8|abK(P1D<_r3y}@n^^B!rxiRQJJxJQ6NJKqs3;0`)Q+_aSSXK29inw`8pS@;lt`= zoAXyUfxK}I9MWsSVG1{_X6wVSW0mt)J>Btlt6kleOI0;nL9YLqopjj~7#LSUF8>ZP zPOM!GD`~i$#NQ}h5!UCf0G{_&o WfCam#qIBcD^jACc>~sD9-vj_G!vc5! literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-entropy-control.jpg b/tests/fixtures/ingress-entropy-control.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cde405690e0a2b6c9c7bafc37d5e01cb510569cf GIT binary patch literal 30651 zcmbTdcT`hb*FK6B5drB^6{JHzrT5oD3j)$Y3FRPCLI^z&`Z)-QR0&N(lM;GBN(d#u zp$kY2HPnPAJrRvT5P!ViH^#mH+&}KUbB{g8-ea$|$9(o0d#<_WeAb`&KWhxPf%-=J z43{o3FkHGg82&6WJY~3i>A&({efhs~^~!(swX0XJT)lqn`t|=g8JTWeXJlf$e*FgX z4W^s_m5Zxe%r|fS_vXJZ`QNUWuU)xv?Iz=O#{X&Z|4IG%mx28T!$*dXS1yS%TxP#? zh5gc>K8Cv&c3%4*^Dd12U%7Po%GGPv886IWzG#5GePRBUD;GvzyRi78b>zi!hO6w? zI7A*kzRqdx!YJy`_2{4Uq8s<0)OT}ROj5)YUk1c5-Q?lD!^bZ!At@y-qok~&s-~{- z^qHQ%fuRx5(#jfSV{2#c`pV7S!xQ2a7z7UvL4=0Aj*W|dlkheXnUR^5o%8+!s<@;S zT~=OES=G?kgl)#Pw6^v1_Vo`8ejXa0B23TB&do0nN#ym7&8_XPJG<0x$KQX@e$r2V z{TJ6IhAaOI>q7rO;$pwRb@{(z!1!NWmoA4~oLAVdUK4qEo#U}Nql-VM=%asba6L&c zs_$mHuV_Kxei<-%lSfR6Bu@P=wEsc&{|;Ep|1V_!7qI^u7m?xCl}i_ccZHonm%*o< zoXZy_G(`3IH%gu$uaxg>*4CB!vyX`0nB~u;O*)SUa%UI%7m6~~mjP;IWia?CFi@14 z2B8qv`p09Uq{bqcX*q;~VVcQPCmgP6Rw+C;uD;bWntSrYvWLH)`?puwHjm<=xkqC7 zw{r;cT=%)qbcAfJ9dQJGrb(%bU7@KfoGE(1NH|#_9Og6o%fxF&{_tKZalqIkop^|Ne5vGF z<8j^e9|p9UUp?p!(axQ3SZAh<+tE-`3Mb>6ZN8arBrUdn6Ec$XP=0w?>ME;`Ok}5uLe-7~aC*R$s?QTMI{XpOD1R8waj>=XIRa`HeBx6pf34{uE~~J0N=t~Wn*dTR z5T-j*i{!Ly>gY16j%)@~FieQU;@2EY$V2cS22Y1>rRP`mLvN)&nR@-0BwcoFp#c4Tg#N_&kP_0_ z!IM`?VP9AvX|G$3^6XW-tdYgtMpGmZ5QE*X+ybBU;ht6uM7X$-qc8OJeDU$oDZNI6ab5*s~%`Wtm1<&n04W zp?mX9`IqLs{QGH1)wyZ)-46&7wvIucSA3Vp&YcUovufr83-5kcSZEmUK2qse>*DDy zeq5$f$(jqP_&ldlAk~}&$TYe#csavQkjOW_u$@^CDeZvQ8W5wsVIM^ zbyHNV%N-NdVHACjv==x@&9+t6cD-x99BiH>A@pRtQgyj`R%P$OXz;R|(FIoq2_n zXbs%5?Wk^TednnCZ+@)mgCVi(*~yWKGs#n%p7vlz1AY2yg#=}o_E8Z<=O3Z66c@iF zfi|%ae!a}e6OKXJ5K_EA3>R;AM!){qX>a7q&W5_RQO#9s?>NykODdez5Xcetantuz zrM%sM(OCXcu9djju#+z>UVA3);`h`RF=A+;odSzG?d68eI@aG)2ey`HZ?|Oz7lh zpdz?$Ii`R%P(vHrNMgLB)qD4CEqG{@p$qc zK|^`a>s=hp4F?{Tp`V=XmWD#y#CUqhh*4~FF|3HMfOzAb-66;4ZxM=fonl?_yd=~7 zwKPtZk_v}i72LVf)R|_jvpuKkVt-~1X#9=gtH26F-}Kt-hhb^kMt8En#=!QZbzGm& z^$PIf9|PKloK%-D#gCv2l7dl$;pupor$)yBtLOEogjyCrSEfA)oXH=&f? zy}p+g+ZOz~WWKh9ya@HZ4Mx~lBpOF5KL3Px8N-~@cH9@jIrc5ONUzNW7I;;_N9PM5 z*M2_F$=QY_On9IsZ`Zy$IiIO?aigqIPF$*K4drEt)-!#O%#{X~>fH$1`E9RMwph-m zsX3VM#5$wBYnkc3Nf|OaHZe`py3hTgJVJUVsGW|NEm4V+Ox!=(cs&q6*LA>6@o2x4 z|)JiMZ6-C}q0WsnulDDif5KA6*^~P_EWo z2+IA(FMUuV{&>^lp_!mx&8MPfD|WTFW;sHhbc=QJc;Xqf^sAHj6i2UKl2fjJ!Kz(b zD-IhoJQYfAZ_XKu*G$^RsdC(N(G;jOXZiB@eEi-7-Gyux^NTU{$6a19#KUX?ws{Kr z5awbFC2BhfCS&qYE~&`=n~nFiE$PM6xfAT{MsQmM4&- z#4Tk-^KbVHDl*YWb01pdeJs>SI7Pp(A+cJ`%0$C7 zZ)-i&s$3pZ?aA+yUjya$$qH-3y%GP=RLC+&W^OEd9A@)k#whE@?Y5wJ_fzqb^8>!M zW-AWfY{R=5RLR%~Sjm|pV=jgdZBeO~zCsE-5kVvW)vVk$~ymt`GioZErzp zCgrD_Fh2t1_WJDwMDv_InyG0rVFWf!E5kIOv}PY!u*^{cXKoLT z$#C%qBl2vpRaKU>R9L^gHOGrA1^0ECCbU$KjE@xwp|0cpyZv>K?&Y(`YwCE(KMXf= z`nJt(j&o5x)5~H|*0t%HuelnqH6Us){&xWxk@%TV-`d`oevrPAKGzqv`npquzTVCM zCN?Oyixm;x7pAFK;p9-mdO|p`mrZbe_&j@JXXm98EGJzpU92n6M`tQ|k9?QgF|zUe zwq2Bn#2f$e$EAo$EP&TK#XW{LJD`t(BA%Oh9^+LkV9Y`tP0`*m-$pm|6F>~DQ4)c*4!dEdN!1XlgZ-d(CX?GWY@c&0KGCh?5xyq-U*^i_$WJ-QuUmKU zLDJLxHlQxKt;*6?ah^>Red+4yymd2_$+wwaW&VhnS7{1-{|s#1`Jyk+T{6T66kg9# z*OTO}j{A+S8fMKm1fEr)5k@olnws4x-T~dXkn?-gyM1a}<`}2NGaZf{)Y$8FF2d~8 zE8b6LvP*rcp%W(=p?*p=)bWz#QbUnGli{zO(v;t2X{7Nt4G6^-)11~_jRbU(Lz^f6 zxJ8QG1_8F+^!BX!t!^Ylyg}|c^6grIe!lls;#|&@Rw&sw-u&w?r#GeZV92{=am*|_ zlBFKkicPX0ux3P;kN)-SI(gaWzMYtz&%1mh5GY!ER@a&WN_R!mIeWu8(ECv`gdfp7 ztImTJyF{&RQ>vK#vpG|(W0#ONqgk*`ickIz#&FNLZsY=k$B+6J{HtR=a?v%5l-w7w zWlQT^CkJTz5E_*DgbM@8l+hbDPf>3T&k~R8d0A<|sc%vJDyR4Qfx5m!LzZDcRhFRK zx}7OpddSMsAB0|))(TvMu5w=l&vX4uLGP6#?GClrJfbWAFnAG{yddIvx1Hq6eKu)e z$oRd6ht-43Jw9TInz2yhkloH})UBq1yA;qH%3KfaSISl~(H$A2%WFW^n_2x*KjZ7M z99mt&!qQj&^ntSkxVlvQWx{n;%-G^tAf>-H$G@DuI2a~91p^$^$>H@#o;5zT-$yt( zx)EIq#LT(pi$1p|N=>f3b?GXwe()qtwu;`nw#u+0%t8;L)OBloDG;0zp>h!E(G~<> zmblvZGP0fcV#SEB*u>({qnZrq56NleOGa1E9j;T3tuaZM)Z}`gDSi`Br-mK=o&xkO zpm;sXvo!V3a&{+wkHGTqXG@#IUsX@~BavH`7T;pbw}E5S4)JTAz`U2+HrX6WiV@6i zRZ!HyABNtv%g!UN@LJDGpFa$gB!sZ6!u~1d2`Emk6f7kY2_&zm`IP*VF=Q(q$|v6B zuhAbpSRg}*${IUQMp|%2d7{)r!!l^nDi5T=uW_Jz@~lwzymL*NxQL!g@7EzpC=CP8%xq#Rev?w zH&OkaMn34Z>I>qhN|zim#Z^{ijbu`TPC_%wWwIm>gyoFLN5p6{Te6RWj!?IcC_eSr zq$%c{^n-=n=kTy}b}QfMPysv0J-W2>&>A9vk`hUrc;-&fO9`aG>Cs&L1LdTyL|R)i zKjzeNhXCmmeK%UW=u8xqM{8x^gXT8D$YTJ$YHE<4$^o))%$SNydL_dCHbhOt+zn`&hz zhq~Pu9y~Lm&B?zA1{ncp{Sy#HYhP`*;cv zxFkE;sb|vcZUeQGzr6hQOqOo@YY~xQNv$nZ&aNZvymaJul;6_6YchdZwag|nbB3xE zh$0uN#$4ymngm7>9>8rInHc>i7N+l^P9&)#sYmD@M9*XTJK6b&>BeC zn0#KQphYe%5J~e3bv+J!`RNaXWB(K|U(-6d#It*>xZM7x77|ob_3c|*iiuOc`b$XJ zpx4f+cGT#7(jrC5dz}HxQ1|?I(01;n)5~c=Tnj@HS6>&*wjK`%=d}UA8mzLnM!( z8Mno4yVe?tnG#Fgr`_d>kR&N^omo(L(6xxL@xGtG{wByr0D~f*kf9KM&obokmnlKR zS=zUnknCN~QKWcydjk7*bK=5pAfi?f(e}a$WtqHe4{MpS+O>yUW|o#Nqb;~yZ$0~1 zM&4)*s>C`t&sfQTdZx4NhJY3!aVm?+m8Bm`D~}{=K9<}Wvswq`f#b|r5dwDfNdLf7 zP0v3JwG1nIe;8hUd;T1G2e5^`G6s2Y@Ve;C zg(~^FlYf_A0!L-~0lU)IypM~Lr1JJ@R_LsjLj0Y^Y17lNeX`1wqk}VEegKm162Bt$ z-f;LSM$3&XWDKN3NUN8?#INQ@5;%e*BdWWxpEJ9MfF>u1_;TU zJ9lu6iAGesTg7|?3wCmgvf8V56roy5Uo=ZIn!;eAd)fx3IG9R!^=D<{3fTE%*gX?{ zvUHN8k_hbf5|Jl7p>!)q#??RC?u!S1=ahUfFh|_kO}ZSS579{XVo&xR89TV^xt3)?cT8%)&&KZ&Ye^b*3%Aybh$-$!S*<|MNv)hrg+|z;^mvm#(qLfIFD@Ox1Y|eNp};vI6HNBcnN1r zlfFu=N_V_7ET-g-_JiihsU)=V0>=LIC-f$Sw2l(l$ZCG@xc;Ndb=AZl<2SuD_W7p} z1k-7E{_%vGAdINM^*L{NlCh?7cvjB%8;h3g^QS(a(@$*tZ+z03)cs605l~>I-`xIp zf9LAFUL7PZ-8r^I^Uq)Vp;~lZipy&+|jYM2Cej%lMJ)JL@u1W5s!5RaRNb z77tBIYi2ayucpi^!%PNB;9?hjI2rZpslJ13Hyt47BGcbx-=-!pQLkrJ+;;#wfx(N5 z^VMFQzKzXT)$6+mRB$%*{eeW1D|1qb*lDwheQ^0Y%yn>;qw7$@8WmV_@l5s!>xDz{E7OnpS~l%vG&byt_aMyXJBX(?R^q#5`T zzbEZiXHn@;rQ>67_ZT(ykImxTFXPFEMlnm8p%x<0(7$)j7`P121u4#P%F%*O(6%t5 zlUJ15?4g#n4-DPZdHRgQAgTpa;HW~jTl_2Ax&wqXq8&w{7UBii?BVE9wb7QQPv; zI4=Dtn7PJDaMssJTCKAg(@YGK~Sf*hKl~6)$HB z?&sxMfqf?i)|Me!%D^N>{Vbz#R8Z?qYxtcF^8 z>n))(7x+4y0T^v69-F=V?)fu}_C}n%&(#7ef`fr7Q*;UaVT5B1 z{XUS6E}8dAe_w-b|1DsSf+!(e0aAFE3e&B}F5;PW($|@DbJ9!VOeJu2&ndd8wb;7{ z2cp{dpW~t0DlG7z7PxZ*-3uaedMQyzg6zpbyP?iun9{bwRGJp~MV6&c@noR!V}wRw z-_csMkQc2}b{Y=5wHt;Rs}6<9*QEaoE5lDIOOd-i^aY6VH%OnFrwSyLw#4c`vv{iJ z9U{-uO(nYxWfJOQ2B)(xv-+iJfrOEn zC!r#;(?1>6TfgjjK?P2_~5@7{=bOg)V4RHaC~#vsP+2!+q7yzytcHKEy5b5QIoHcCT+LcohRD`dG&vt| z4BDAB-|_$09G-Nt%~7hdbqOK}rRAX14oX%9-S0|k^E&-5Y)T0yXP)1m`hmb3*C7^- zX1+&)`^@cj>V?cDvEgs0MqS=M_s_NSDYk=cwDymd4Q+^qire$w?K_3C-E^Yw8Bv$) zH#hUM^HO-@YB3}ag1P;#`Hu0NPWAkhC05Wk5)wS1pSZSR8{4}Q} zX3l8HC?a13CS_KB8+ehdoogjQOw)e0F^Rb)EPtn~)-6-gzAD+lm1{9MVqDS`d`3Do7ezqNQzP3scwQ6+!*r1)12R`1|c>Q|O+s~2lfDJ7zx z=XiNDB8%@nBDuH0IhKq?YdU<=OAzv_{}Ma4`@llJy&6dfQ}W(Gr?_*%@w@{)%&xqZ zK-{TyYlog1tvrOl`B$DkC|DM@N4ti>< zMNNL!)ax$zj%MLSeC#%kU*DM3&3x8OQxNas`YJ3zkEFB_74EDc?&>!pK6Vg-x)M|y z9d|+1&3fTX@>osq`;Uu)=}8yqtzy!e;y-dzd?Z?Rjqr86bnopYN$aN{6x?L$3p;M6 zzSy9pa$=P}9(i0-t&{5v2pJZ!uVu~1l2{*6@b1_)BN~kqD7HVJqKgNL5^C z&#aZApHhK1UmdW2jxwUEypAkGG=jh%@JFIEZq1MtX3cQU&{rb$Eh(a_QhhcL8}Ai} zgp4nEI&*8;tjVe$!c9C(32;nk`4m2cgxH=u#CqbuTXFHhS3uhiWFH?;}Ay(%Yw8J?e^q8epv_ zTbozk)rf*abd>$cg^AJZK`BpG$>${>zeWj9VKSb#&)IKz@`0p$Ts#k)u4Z2z;aJhH z@An~?N#w(PRZgCJ&AYWYynlZ~W7%RK$Fx zu>2`=Oh!3)aV7~FCnbq!%W|;r&1TW@O6eY4kebAoNSs{Mc1katD-QRm@e`Fh_U%PR ztDxF9l%${wPMlTX2k$nwMLhbPP1(JHTy;3f`T5XZ05i1+!@aEO-*`6r?7U&@uy}9teE*_qa}0qWdm=4oUHs#;y#eG#xolhPdE(N z;_uT#Ya<@ET_hfk`|RPRVW_;3CDJm0v=iAfVpI)X}YOU(Sj{=jeN|dE6H$Kkb38(TIqkb}FsWrY# zUNdULr81J^AXL8oG%{0^P(GrxGlO)X#L1s2l+r^S>5A2epM724Efd&PZba>|Wg(F8 z9X!j#aCK#rHdH>%f2ipr@ov;j$^Wxf?CoD{|3uxRC-sIcF3r=U&8b3v7?L8)#a5i- zn(XA{zUy-AfyY1V{mghzk_zc&f_0N?EXOAi0w#lZgo?4C} zI)(6xdD(>>9O|GSRmvz)o1T<~}4jX2vzobY$E3I~!y6x|=f#$V8^#yT|_a zO^2CX=M{g;EUw+I$`>WlUQP1!QA z(bZ300VAjLy;@_lfzQij(zt9!zU=BqQRSROO*}}n!ZJ^qhV?Y4K&`2Z+FTOq4;RnW zT?lTJ0Y?^if^47`;&}^t+nX;r1J z7H(_GUeS?UR;M|*FlvRz4u=*u? zYAzy#eF=bfvRN;@4e1nWqzv}j%qr)DdY!0=3-8bDiY^vY1{LBbHgq1JD(x{2ND$k;~j7jK;$qSe&^ z6x$_`0+WJ=o2>ef5+S0n?dSai5oCLGyN_nYhR=S z)aN?ypFJjMu_?sQZdzJGQ-DY26F+P^J7T6j8wc;n^=Ti_jQ|OW+)d(Be>LcLCb^KM zm6qjaf3DfYXmMB3vxnsVq6%Zth{Rtz`$u=5HZ~VP4Y#sjq~=zSY^hCI<)&|okEL&l zLQ-@Y5P^^BswJv%zEKKL-bZu=gX~{0k7v+P32orm*6@HW@bMk1?3Lwx`TLKECd^Ox z;;lh;X=o=Uszq#-GqUvM%#DHEpr!JC^0K5^dTEh9U^dYr%dkbi)YW6{<%}S}%d7c5 zGO)u5v^(JSAni!~Ro)TrsJWDUXzOD&y>cn5B!iX?LMJaGy1(E){RbJ-mUUPdim~-F z_u{P=avoZ8Z?R6@+SyMAx%W%h&EONn7Wp5;1ub+3I<+Z$!?ItCJSWcy9Hq2xCm|Byf$aM42Cv%B_2@D)CiUouQpd2>J7L(( zgP#ejPB|(zv$$Yl1jweLoBfe}V<XvHu4&s~9)!73%|8FlFT1kqZEe1?9pG3E( z@Y-9EK$#L7$h3wni;S(Le7&F_DdaS#j_0+3*#fBPt-NwR0~BXqEWc$&vWuzDi#$lz ziVMl)+n8^@z6##JgQb6F^$d{4Mh_Tw$~SH#;ft?x`ZFTw>^=67$6X)^;}y0`8?bR zQwNyZ&~7q+PAgVdQ3_~UO#g@|wP{PGeOOC-qtXV#)F5!?vjF9{+iiasVsi(C@3ivU zLrj#~;Q0h4!Z=J!m2(H86f;HRG0gsc!-t(;GJ|P^?J4I-4k>ptr!muQWaRG34~Y;0^HU%=HvUD+U>#G3m?p6k3oc{n$iw?Lix*UUlZO*+)3xnu5( zxp-_v46&IChWR6h{C4tF%RA=Ds4w_z6A`m4h&3jVhUkg11>qt6D}WuH@qhMr^w3O1uX zNmgjnyv!=Ah?U*)QF#wuR;x|vfLbGU=E*m+ zMn_1n`}}}pqJgn(Y-{%^cRSHQv>Lo*{MXz?#Gw^Rs=J1e9k9`p$3HG#qBnkSieaG* zg>b2acuo;VTE&ksWht<$kDk>E3}Fd>Y{q#MJaU z?j~FdiYPl`dsf=C%y7KX0!tTwL+-U+Rz^x~pzs=RdZBmOyVu!xA`}UD7f2N%X3tW? zAZfcHOQ}RIpcg@xyVmEo1R?AT+ve%{IoUeFV_yAOVHDE%!_XE745&6+qL~Bp)U`|E zB=V3J`6{{Pe1i+&luG^#pXB-ij{N7WlDtXgi7C1eCi4tai-0awamgWsX;ofFY#!8T zB1^v#t#owT5z%EpLH|w3AIMOj!~9?#eFhEl)-b`}oa4n=IOzMpFbB2SSEodIA0|W` zbANd*07=`&X+5lf@F!3@{xI}8f}QJU{-XR`{Elv?e3>1u06QjlW3R`P%8Zl++=esZbmK>TN#0zBYn0T;pdEV|mWeot6<@=Un|ylhfqB9`U`hgCkN z{VwKuuT_Fs8(CXIF7ST3+mOfL}8de)6W=ok_%Gb;mDq6(W?~L8f zJNPm!u6PY-=Pv4xk+N=5vRa<8wJA4u%KM1^JS}-T7OH$v>A7GfeJYYn!miTwwqo|k zWofRYG*AK6GS$?MMGX-y9EeV3s#H*~dF-Tu{Q~KSpBV zufl7qdu{B?8p&XRQEn{8xR;ogAl5Pv57KxHJetHt=9tWQi)vd>e*Ypv9oSOb)4(PX!1Mm zLBoc7i@-aEbg8Xa(NxT>dO^Fez=(?`kM zrP9L+1vY_cvQ666i8+mBDA<$~-4Z_o%*(4}CvXREh(Vi&nf@%oHY1MA*Tg^nH z{fN+~=|kW9G1~{K={+1XUyOq0YJNhPwGNLXJmw<8$zTAlF}hC?Mk|ry#RYx3v5gKoWOq#BVAP^-Hpx z1N=74Q&5{hl{gmla!vUeFCgnu%ZYWUcagoh?Hkn_W846N>i)5xdD~KDdsb-GgO_;% zKBX0>PJ53DKUS}OW&gT*yUEy5_xW^CWEX3MBH7c8@?*B3PRuy!DOk)ntZ^iOrvA?# zhN+wv9*=qy+zqP`>peu9zqKyaodGC?sW7E+=7?uhmL!BG(D6D?efUTjVH4NVm7DHO z;?S(Pr-BLGHoy<5?0}qddd?L(D9(L#x9k3`dvh6b#{Tg?5i&%!n9Y{v`cxWN^R(J{ z#1?(5lpD(U-X-x+Y6jrKJ_P44&s)k##&do|DE3T7Jf_$r7Ew7LM|(g@^NqiB;Ncyd z^Cf03zG~qqI7}!5`=V0DjoQAEM_I_#v@6`nU$RA<;*?6{5yR6}rt%!G{gWeyQz4Qv ziSlrJzgctNe(MK_{T{?E=f;#fr$F;mWb*G)m!Uh;0!CY!I*o&yza0=swepyULQ@O! zuOf8cu5uqnXs;bHJ$5@)N36e?J5UBlm|^Kj|4B2=CjVLnST~(0th+r@T(Tc{PN*4( z{QS&wc4EZd=`NKeHi{2Vi5^r)A&T*r5+h?#+L)P^W=9}V)(G9unc{SnmtzqSOPaEF z3A}Yvc-M$qctzkTCDBex-Gk)WWMU34yyoB~dqJxR@>sPrqxj03o5@^|E9UxEIYsTgd^Z}RlY{kl1)=NGg`oU!vP~x zSCFleZJzRLR+Y`7v;;_<4S2DgEF&o$t|nDgFk_i)B~+>`(J;r55?oh)W;S;S z;I*4h1oU^sHZ5@ME-Mg26(sDMcJ6&JikY)SE*)@`@`65gb3SRX(ovey5!-d<_|7Z@ zC3U@p?~tc!F*bb}^Z)^&CwzA6ZLNt1UsO5Au`|h^o^71izeWz8J|7V-e5=FjaK_q7 z_&%O+&PH9%Y|DOA<<%VExgs1tK5CGU0yJr#@h zyrrlYD3m@flu+__oD3pF0crs(q-<<54-HMlPFI?uLF>SWF-ftZd2Z;;b}H9HgrW5y z-Ue5krOZEUp4ONf5c8t$1|ec-IKZm}ZQvl(OrwLT3ugGV)wua{pj=OL$Zc|y3M!3WT|_Ji}&>@OW(fgh_zD3W4wP9 z-hy&C9CkL;w>QKobv)caDmTIOChKmccW*EDB+owjU9dYZW5CJhe!&ZcCmCI2TWFlS zyg2)=H#(ygU$6fFe9zZ4z$c#nG>=io#7@mGe()&^>F@|hx^-iYoeOS)Q6&3xT`*GY zx8K57V?zp<3J3%I4jLYB<>3Os_!N0qT}ig*J8vBF8^Uo)Ht;QXTuB#^XDJk~^avkB zZ5Ws-f>eD11Kn4FB2E(HmW45~D2aTuCAon{lffYN* zafuwjHqipAS={LLGPGy0_%|ADb*k5>zehSsnEMs^hamwkP4OUiR%@SpN~8_;*mE!U zS%N)jA@6YWq_%XEJ~!BXo|mC32!4e}o@)}uF|BM=Rsv}|`Ah>pg+VYP)48On=Dwe%)fGJCR(TKcLD;D%VH|ejd55?;s6;V?7L}NhGc}i(k?#haNPf9)-aUkaTW0xMc=>JQ ztF;np(PF!5fqJGaynRkr>1ox(Mf%p5wy*Ita+u7VV(<8Htv!)381;ZwuQpwl44G|? z?c0*JJP^|*a>?Rs>FDCSOmtjt6c6R9NsU2JI%I{*jK$f2WnY!k@F%GArAZ**UnJ2E zR{UY+FJ0ohs?4h`HCh1D;$k(M@Sex>X;{Qv|FYd?-e0sN*Rpv%^hp{kE~KJoO9C0x z2IB0gHxYd#_P|~G{gV3%LUa9kr5eQ}p$=Opw6Nm9RgX=xn;QG4^6T-uiJ(NkNb9L~ z=|6v|%ChACE!qhihgz3Ua*el4C?$dJ!WioK;5;a;E=2u*Z)qHLn+GKl}onuR$#vhG9k;9VQWkkY~)A|>?Se~+mPC2tu`V&JfxAj$)v&Z=684qJ{l0ZJQ zLHLloyqI;XD_lAGLI1V>{>@Q5l25)Ucf9GFd5N?b&W-Lu4e86t;r|&Q;dT!{ftS5r z=%O4~+(cL%5q!1ewDRn&ub}ypTPb22%3mN%LCtSr>$xb3PI?7>k$hfB-9X5XTYfqp z&F$52oUKF~Ur!6pd9mW95}fXplfJv*2^nT?ox<2N+SVx-4)XU~} z2S}Z}A?gP9&g@JP65143)1#FPb4Tjnz*Kwsxbei99?4N-_+q~Rb`q~$j%es1?y3?b zN7oFZo4t&;c5nWCf$ZLTG(@{o!0RHCZCl%v5(1*s#hn>nkji3Iba+p%qgJ?Q?-PD# z>o}V3+O#LG42*@Jnl%!auNx#HZb{5nQ;-#?1-2}(KnzW>$j+WKO-^ic9Q>g>uwUt< zd|Z0m`cPllMOu<^M40Ji<1Ovbt$ItJ{GA?EnvaReOcK7MN)#w3z$8@#<~G_s=d*G=iMx`8@dZcF0p*l z6LV#doum3D;+@0EW(oY;sah-{@G7TeM{sV}Ew6)DaHggZX}RD7&i!^st@0%~;g{%M zyBi{j%&$dH+PXN;z(o-%sjig0fB9`_1BK*`ju>VWi``B5hw?OwHl}N%1!9E!{=ZRN zy^oHNwjWcC^pE8E$C4s4tTD^?B! z>J+f5$4kl-fh(zECs%be(%E$mSD<8y!H;7z2;s(?8 z)xRI`jIH*rND$yIfiR4-gGxaTEEG}C9(t9c^L|eCHV@8HD-IAF$Nq}K`j%98bB`CL zHh1<|qTaXs4?~YMX_RvyIJkJ&7>*JM<+S8AuWZAkV2@YyaC&mh&yTNp9NWnLTOdgJ zofS)rPl~MeqIgBgcQj6F8N*AT`pn&aS`k?=>!`G|R%-FPmNu-MWA@Kk1?^uU5!)$y zVIC^pNstOiImE01qCq%na1JcQtrTiuQrnB(v+wwXl68AK(Hp(l&{%4tdBF?PFcTF^ z9~5_zm9Bih`D5GEp*CNSC6r4j{P9?xcBv!}nGmaPrM?p|)G{RFKE3-So*x63_ckt9 zB4a>SRjp&MWANppU&sWs3~YKU&eS(7;#UC^CG9ods2VCP7{e0soXXOfRB5~9bvwxu+9si5wrxXi+%Zw}QO29X4R@@&m&n;x!cs+F|mjHW^l z-gyk!0^|>Eeyf=1Yb&J8oj;_ireIfv=sE;2G^>?zw!%e;ZR7re`&DWmhlhrE=Wu+< z0oKtpqg8xGFz@HhF104AAF7ZL*7*CR6W`hY)|}>H87tJ!N(&`S$*saq33mqfca%WDHikWE5XycI3tZ-ytI! z8Y(@;=(>%^a*U1f3heyAxfIgD){cpuRbf-W&yAwdYgbD&5(C;ZJLp}5sE`9@a@Q@# z2pA!Xf0w8|p5lNA?+?pBbi!T?LERMJ0_r4@Bnz^7=Qs`e=B){Cfdmd1k>kMPip~U^ zd=rRTPK=Z{Z;xt|`O9kmF#Ofo5)Mi=99=VR33+;R=wfRDNy7E!sL+U*sp^2ZuhfLn zfh4K)*IdGx2>Bq;X;PxK5SVMx5`PYcp7yneL%*7AewoCMwnw7iwl%fxp{)7*iiY(| z4x<@mPG?HgKMe6__p9YlLB#x~i=rLQf-;at3$uAm02x{PZlDz8SIy$3lzQ8kV^bDZ zLKi@B1lhyV-UK%x{PNE{?*VBe(l)Tz=r82 z2_1e&HnyHswq-Ch zY&G#?+sp=h?g)c1{~=8_d1@9na)RT*S$KJ;MfSDc*k zA(UgzDRLTa3x)VaQ>Gq~?@4wF!~KmePuRfBXHv z|Mqx)9@q6cJg>#NMXFO`k2Y=#25|LoUap4FDcaSQpOkrgqzY8OLuNWqiOdqFeS3mN z=)T}=tir0>VcM)BNc{=%_5-Z&*;WO+`ty>0#Zc|eyQ)g*mvm?O++()9Z1p7RmX>MAwB_%tDv4#{ z{riY%Ezzy0+da393G@A~dexW!2j>7JadvNcDEiv)@$PDmyGnD^Kw`_$bCkBTLn zPhSi=%WRHD20jRwp8=UzB0}QsAB2cC7ly{UuMCiT-<;T9q+ap3(D7k@?3nL?mkdSs5t4!5?iW4rWWnX~lA*Mzn*m22%7nfr5Q=8GD=fhNUi3eD3wT1=m*i@T zU6eq{x0U|)*B-Iwf;I+fknft67F#p_Cs6b=O0l`K_!HDMPL48?KimAU0ypL7b%fo>U# z!<7n4!qFv@Zp&0tl~>1R6Iw@=XXdGr6pJl@-t!fH9#tcvYJg?A6;CdwjG*$=79ypT-xtPFI+DuH&Y z9^2po#wU7`dq4`hg~eVrHBI(FcU8N2{s?3a`sO#xTS7Q}ch}$oi-Y93&MpSlfWXqD zC?TQW@@a?={$H60ePin81Wk3`2`+2#HuP(`Amuu56X#`qqzt=;FF>p7rGZUG4TVpy zJH?c^jxl*cMwp46V9>&S=$-=|W___AHgd1Mxi#?BQgXHNz_yfJ3B3NXALVlgtI0pU zgNRHzw@J!xww&n;IL3j@aD8khlZh%F5xB6 z`_{J{gpQ96mKvSwbJZ*=7o(V8oE%xkDl4~txw~$NdB?mOWWc@n5ZYC@ax+39+!&%B zmeV8=*M=@Cj#c;&mfNVH00}1=(LJ*z1Xx-asAx%lAukd0wFe1}^Qia5y5-cl8#{mQ z=(>Yfg6U^}9K)W?R(bCdo9tT@Fc8W7wUB8bxLw7U5YfC8P<2h?S0sx)*dOq&vQma2 zeRtV$``vaWBk}HBi_ZCx9N3&d#_f>2%-9S#(|eNKS$UQzpAPV1tlPZme07)ed?v{t za!LDpX0h_+CX<-L)I&%W3@TgO#6N!2yK#$jzxS?YfzXNJ3<=z1!Z)hat58J_cW1>3 zzna15IyCano4=aN$HeQD0J=S11H%+=PXfNFUN6(gc{gk#W-De^8<>2#YvM4f*k@8! zXF_A==IH4=UbMDxhICOj5`N82QE|~{nAX-_0=(HcjA)!DG6rj(+@6rZodyC3cNXZxxC_*3|l4RrlJaE(M^^Qk)`eJcNVD) z-aIP}>eGtk^FW>r!vFpvukk17un_S+bWO(a7~MJObWQU+)vu)ov#ah(U3z=<-O*QV z0r7rM7KV~ zkMb>O>Jtl5{Dsn!XCUapO5cij(6esrXtT;qJhtl)oS+`a6-WM-5a^bT)*b~_plUMF z3B%l37}QJ$2EwT8dt)@phJ@(iz{Z?f2EY~2mDwT;hnWqPDZD4f76lRvNKC6tfN?M) zkq^XfhD7F>xE!0NZ&C0gP9&C8ciQB*M)+|28Kt}_;sk8)($;~&m6&K+Rc>o=HRGQI z$h>)`@`)CG$~3_!1Q6#|G@00F)`{Ysalf7nFb|6rB!y*(1qEQ7^bI-d>KXMehWO+h z`8(U#Xt^pM7$)Y~(y+fcx>-1a9It?=7*kKN{7!t5()RMuk4Q4^Py2+v7}GQuvOaKy zB>om25-WmT7SNQ1a zB-Snr8ypf>?5S6wKpqXlLZa%{f??@c7FNtwPRTomQ`IUpcXd zd*|7o@!fr|cn5{xH(0r6YeDHz*(=oqVT<{1mJu@frPEuZcFe9^<3Xc|s*WUzbzS-9 zgWQ8y<4a&u3Qm^O4p}c;>#Nx2`Y^OBP9xBMtaWAPsMMOh=ee4S0PS*Rs+8Abh7=}j z@FnZgGYK$uyjR@Wh{gig#udar`T&hfJjhU$1a%(Y_ppB(mwGose-x0O<)x;flr-af z`}tyO(LD2P527GVBk>GPtsp_qzd5k%4~}iq>r30rQ1y-PrP%i%ZH~p4Bg{3)!}{35 z$G67ybHO4G#kal6t3;s4n8zp4Z25$LCrPT4nFDp1TnmhT`X$Pc3J>qy>}rpdtujrC z?6rU9R+0zbzfN6+UHEQr;{?JmsELKbajZU)G1c!?J&-|}NQ5kp*NJ7yAuW4<<arWl6K|PcAH~C!s9Tf#i}!l`EU0k#TPq;@`H)*4~++?1kV5 zF2}_Eq=yVTMIP@-%};C%OK8mp`OK;Mbv^<2bkBH>m&x>63Kk7gE>Nhj=QXi7c%{yj zrh^zFUF$#PJr$W)jq{A10P1a+#GRRWF?U;ATj^y>?;^sE8_8DZseU(_;@N4WEAwTB z!>K}kF6>$0Ra0RI+Khg{8_*b$L{>0_@x}$Jv$x(1)uP(IDn&}2y!vDZm$uWO4E@iW zvFBz&SY9jtaoc5f2_$)T_q&Q7nh7NWq^_m{;gaGt?ClLT>^fyR;)x%~u6z2m)3KCV zS>L!VtlWI`bHUN^h|>Jwa*~bV?y--QqS2kIS9M!JAH8IOli*6lC!Q>H7(Fq>63>`L zIf`ZvUW|KR#I2z0JNvM^J+e^sjS_!iE~ z5o!J3U+YI!cy|+yTpM#mVf#)aCkev^oC;!^8`gh`8C4$vRT+IAqFIMoe=bCcf_;eR zb{VS;E>V?y6tN;3NmOz^;#sps`TJETeP44`7LV-du0lo2lHQre=u0 z_6L2Eb4H=}5U2byTq@quoTxbX#EScmN?ccvTdC)+AtG}ARpgG#9n=8ChCNP#O;@Lj z)S}Wbo4-=!vjMKYg7d8yX*y-qIIGXE9K*um z4013zYq*=|)m+qO*IW?U#a z`e><3cpF}D$K@+sur=#@-ko7lz9;qXNt&wd)5*1ivG9mS(|~V;=35YNp;tg(DK~1n znFlawJowCbB200qWpPyP%zrgjg08&lX4b{-I*R<|)HEH;QLvUOH%O z*_3~tnF>f8=AL7v()l!a$~U&@Dur8msh>&NuYG zzjCR5gj!b5`cTh&&AC3q)D!dYpK+IGYI`lZE-krOW@vG9bo~3e!u6tzK-qvVjqMsb z7|6A~Pm%{V1y%~qN$8v3(+zHlmTl<{4*A>*^gC7@4@?;McY4YlqP^_#oVXMsm=}#n zxcBE>2Az*1rqh#S=PkANch%89<3d?7KTFFZZIc6KxG!}SEdsjh@?mu`{#sK144G%3 zB%k1(r0&?%UulBWdTMnJsiO+ocbfjFwI2>!A;DZqE75 zwb+bqZZ2+I_J~#91|)AQL{~SxhOd81(Rv=zvt4QM({wILdl9DQn{awH(c{peIL3R% zwVOyn1Ip+7dgjhQ_a=INp}4z}92hOq1eCWD$S-$Qf4%0Jj_y7P@Ak0u0#R1PXo4%c zKpF!AHGi(H+1DYfI`>yZ`DRWqY)7nLnk?Sby08kdu}Pw+=V>2Xv;_3n*x+ocQ;z23 zYQ}}S@l5mV9|xJDYAL%X3SKmH;Ff7ex}Fu6^I)w3QlGl=>bcAAFuN|*P)fAO(a5?} z-J9mjwtIyPQ-udEC&{oB56HjgF)w1$53Cl~r|#H(fQR7wB)+{KdT+c3h;Vn-C>+x> zKhu3EHM0(%7R4;L7SLx`h30-df5b?yfeAlWm50HqGLHl-vF^*@`dc|x`L18TX0CS@ zQFWvq&n-+BO2y3i^_%A4DG7!xtm#3=ei0_epO%^z!!*}PC|*y@GDpuHU@4x4rdeW7 z2JllnwUf%X(_`sEKdpa-i&Q@+#JVwKQ<)#jN^aJxA%^xe4`f;yGR1w>#an_xY}MUb z`<>|RU)Q9HuWWi-d+~_8cj;!fNol8&ObUAGqSf8rR$mdV}LVe z&6cIGe!vB#u4MxBt5=p`?N-=9|3+At%L+Lv7_PQ18Ir0$)$RR=*qJ|t8~#O;PFX(u z{jQHLp6qmKJNZ(x3Z1!+YF?#!l=CT(d3Z1*VbtNEj@!TIvgDu`CQTUE$N8Od^LIZ zq4ldwm%~>|m+fhct7(Qo+ta5>Hx&a_?-VPVp?W2fVu7aSYe(VDwX>gCxA2Dm72V>S zbbk@Al>m8PIhrmb7Y#Hu4lHWLI2w3BRb8O*@7$`$2meV5Xy_Y)-fM7sUJ3Zw^AI`R z;ra%y`a`Ut24&SP`Uoj?_E>KU1`jHPk~h;Tt{1)sq0{nTG73PH?*$^}^-$3cr$EYA zQRjRG+@9t<+ga8JDG8q06T7x_&OV>6Q)%B7i<-=)YmUwIOJ5)%=m#7pX3P$;GDpLTi#0Yv zEyzfcFsWXmo_C#e=$XmGETUC|UzC0s!o$L?$ZFy@Ya|GM-~U0<9N!)KotOVjs~m2^ggyH25E<7^!JfEiCOZ8 zQaopc0)wSjuCZ&<#8kTHT6iZ_ycFq=VF@3Vu|Ci|MfEXz8{boR3j3eUtDO-`yha## zfbCid(bXI&B#;RNa`OYd;ag$R^p?lW%fSH6mnR+>mMlcQ^Ukd$llD_Om9EqgP7E^% zRSqsQ3WC?ns**ovV?$IXfSQvKR4;z2$!aP7bP)6)ce{(+xXeHz(ZXV6^TpYpSl1#* z9P!+SE2Tc~ZRp3iXKr?G2a+(wHXE=ltcu#2q-rmRY+TyRDOtCvMVmrglBD08r-hKz z=oW->9gQmCnEYuViOErlfi{Tj;WtvR!o4j_Yp7%;*Fv6Jt!Suh@#SUU8RNf*H3_;5 z!y~h-hLbxihu<3f2^%SW&>QFcBu1@=*E^6uhyCjR^iuQ1s& zv(n&0@YradC63dKbIitwUP2n|`C} z*?t1YyLR_54@PzN_6d{Ru0IB5@QXo|>usQg+hlmaqoSWYSLSz$6uPu-HGdI3kJ?ku z9dm58;$**2b4TXef?ds%s3lv)(C!DQ03f`5*SxKBzut^D zwMDOe?6D#Jsu`DXi6Z$exwReTwYXf`0hq5tNv^_KKlT!vLm~~uVxs?r-#{cr9sP7X z{V+>`df}xm{mxd(NAGs4nJUsr66!)7P?%BsM&y4)O=fc}`QAo5di}ux0 zPh@}quLQF5*?O=5;adRauNjbxR(N8uiT>Wm{7`T56A2X-HooZ#I}6VR2*HxiA!Gb! zeVN6ZqoiWH+70tbO~-oK)E^QHL9dOWc^Y@@SF&K47JYexA6*E&4n@-xG2=4g^^@ zEW36ftF&YSx33N{t%tkEA*%bFV2Gr0{WE*~7qgE!WM?gHW4_X2e+cCAleC+P?dA1K zV4yug$Lv*S&zZoaJ98(%8n@sNvLA4Ws>`lB5=w2~e)KMJE9sfpk?=%fd#sunK}Iet zJb?*l`Q~>a=UWdd3m=kF#dYGvH~=$#4&!@g!3&w+o0ic2yV1xs=Rjn0$JC$31KDNn z5Saxb%8;zm4L?(~$33KP%K^!f{zu7}@hA_@-iqY(O-9E1G$*@M5HCr zNmGMsbkSf@WPmqN$Vv!nU1y@Ww{R1h0-@L6csXI#K|CtC}lI$y>J{dXSPaFtH&E9kQFj3M%xJ_-HrYMiwW*l;2?lA!x3Aydt=J z2&E!_b8v`Py>43@{4M?|yLxH&qI&y%YGS#&Bspr03jvlUugbD8>sB3sJ-Wb#ufK;Z z?>^_*{g&3<{VF2xr%-m$HaJ=(ZLlDrb0LhlUpy9>*UB0i0~QSS%Xc7$f4c0Y3U6as z=kMYBI%awLvoKcB@TbdaR8>0Raj>c&acN9!NZR`5;r-Ys9Ajz3MYg^G@kh*r$at5< z@vf>VV3jX@74gbp*2^rN(_`685O zXPPia^K8voWPX0&Z1=XT!%v&=h}78r^s5OMUqxbP!;XqJbH9*v;uVW*s^-@HYIKoD zf#I9E8#=ZLNDSBIt?vW>p{n@2vbWq>1ucD@#XXNiSG$#VI<1JJ{h#zRjxB*;P@- z6%A?FbrmiDGOc}1w?$MfvwfQ8aJI2vSjajU^OUG(7-l|X2&dt{@|+ow-~#KB7bIjq zta4|;2TR9mi=32Sa_Zz$?&d1--F^RYc^M=fAob+hKVw@un3xa6iq;jjP181R1lj3; zq01?@n>lc2NH|!*_*O+R*5NiLuuI;7M9sf_8wn?JPE;9c-A?7-EZ(em3BhA09FK|; z2(^EN$O1rtEb;x8fAxF?*Cj` zCmtJPK3!IpJ8}l|{nOIgy1W)c9~)x_h6~dCm=4IA6>}VvnnrHPf^nq1onPqP=R_u` z_?)>KQu7U7x#{%MO}K35e}Dal&-%`-A3tKCzHp8$C9Bk zDm6H_21{^CIF*S!y3-tL_My=Sl+RV_`6Fu{rIfdc#G2iPDjB1+F)?;6-UM*mbj;hC+f4`S8U1s*Aso4Tfc*& zl->kU;(Kj*8fIip-_IeB^=0$L`ttmRpN^VU+U#kBx=gLa)C`KA&mcrGr31s9RLkqZ zbS1FkV3Mqb#-1wOLU@m?0vLB0x_sNy429ecFh$Q1D_-5}_ZO~C5M)z22hQ(=22`Cv z;QU8anxMpRtGg9al@lsL&iLaTSX!)H=8i(pD|mZ|fmJ2vKbAdW>Ak*82}Aav-pQ~U z`pTw#Y@>P-e=%kI3qk~Imffdj#X%yKDZO<5BLLNR*}0IJ(S25XK$JylCL@ zqV80|uNP*yv|KBeiRtGHbs!pT>_f7S6He0G^^TI>udOM13W5t9cu@i{yAe?j1g^|3H=<| zWR+oXhIx%WNuNl*U{!naLnEMZkjCFdF5(7X_o7QZ{fV9ZC6sxJwE#BtYHf;=#i!Ey zSN9%$`SBcwSK+h%aPl{!^U!!!IRReRnu8;#c(Kif)E3IQWBm&4*itz{8W=tF?*_k2 zZ~pFur^@zd`Wip$32o~!LNep7u#gbjia9a7?QZb$#i}d#<#xC;juic1X_9+boV-kK z5smq`Y?b?xZxQs;Fnj>o_$B!g;*aYozYt^U6x;Y!5=LQ*1_I-u)b!Tc^10X+)yU%u zD|V*v2J^tTQc)|>pS^Eh8UCT6q!@`^e`Q_#Wxm)K3T}ysFxDtaISMUSFn``a+^w-b zMu;eROP1{3^t%0)KTS(CkoU?KCnWD)-$XV-v>hl%U*Eg_a>^ml$^YiRG`#0uF~YRF zFPmAdrYvWPif=o(GhaI&*R_{YV2H>4D^0@?(QyVQd|zOu4JI$Q!OnOu2b!o9x+>%ThNugFe{#DD6G z44`(tsuR=G*K(Z+id;gwW4bKwT)Mp-u)s(xa{SNa3}&i1Dg57}Ox!2Pq!R%NX1!$R zF)y$(K8aafiirm0udWZ{++NHSyr=_C}9Pf|WKz#9DS5|?}jzFrn?8SeTeTL3@ zOoh)QL3bS5>x$hKhHj@GwrC9S4A!e+d>MSEZEPT|kA6!YoUPsst=n{4E;Z~cpY+|G z+jHDXG~Nk7JtF?yJg@O*eS8aHpvm+cjsfV!uTP2#kKSU{3@5QS0I=xl{5oW&{&7%H zi`Mj2Sh!z|_pw-GLwuTJ(S_jpbSKAg0|A!ppe|q9(qO?StqeBswvlKc)IGNc52|hZ z&sDQHnoqDv&&@=K`0%S>xiS}Es%Ub{+~@V-j-=Q=UXu6A>h=811KU@#5&x_#vCvEA ziBK2V_s0?l-Qushy_XJ+aW$^ZnT2b^`4y9YKOXY`(TCO^x*8in1ka{IvF=kK>pq7K z(ZGansnAgfKdG-mU|`SGgu|>;(Z&uNp_p)3hD5{ELbQ8qH|%xRfk67TSeHPX#x0Al zNuno4qLA+9!+DV`wq_jkjX$)s>~|#g$d<;Z`h<{H zVt-AoPv^LtPyZg0VaSzWPS10Qhsy~OyYP&PP5~F_Q1~~ukM5cs;7fUO&u;b=(eJyy z4JOswXS*k#WqqY*JW0^QuzZ^%VLSp>`SZgT`F^P|A}8YVA<5_pd^4wf+$YZmNSd?Y znOcq>vbcWe7n1WMIY*Mm_Z1T{odYlpUe)^W(!j8r8JzZcJL+92)6MO5r@Ewyo#wO7 zvQ?mim6DB_B~scGpYV|@GUA%2ejKP+fy(}#V}YuX7;7`dyY(PuPVRbqZH*!O=ddSv z_k$F?DXj#@81TTuU-q(lUa@oYwiPA1`F8@LJ^AWL8p)7nD=i_chP4KsZEpS!x3ZZf zc%BPzaY)mjBSm+%syzE=5$-`>v+63-XJ6WpgJsb}?;CSGhaVfJ{?^LuzZApIT_6b2$@7&>VkJ~+@SBP(?|i76hJo4nCkdu?ECJex zAs>5CIzvp6CKL@K&{0DN?|PUj$C`Mm1WL9x0x%x|{0oVg{oq;~{r5DAW7ssjyNVAW zE6Q3L$^AVNEjqbmTDx5q{PcI?=eVezuYPQyebL1j%P|}9C~Y%Ms$|pbxsx7fe5N!W zuq5(>S|YV4r~rY8+T!a)>;qNc*2m7eMG*DcU)~t5u>F$z9RiBQ3hT9PT}kGm!E6@r zY>nw?1(sk_8)Cz6^T4rQl;0Y^uWzwVwQdWM=T>py<)7y5*&v-{SSD zkxe!2Lz-P3c>Xc{qfxEi7sMjr6od&6hVbUL23%MN!8UQSPPLe6#B6*k7b~2t_(-s8 z>h5Ey`z>fT1CswZ+6>Xa}A1<`|y5uc>w1wEP4nC6+DTYa(%6PgnsF~(^FdF zyB^^mS}A970MJ`MV(0UEsC2f7?3hZ8OT`TbbS$Jd z^-4XbX3P|MHn+*h3{)>;?@YXT*fn}PI|=~^{7B-JD&BJ0$(7baN33Ewo#)UO_ z402(vv?Q56=F_O`4$%0s8e<0u zYcDU_`We^&y0%P#02(DIVc?si7T<5hSnUCNVt%)6M$+UJK;YBu(FkO*@02Xm(7Og~ zTLI^oyb9KQ`{k%CIEw~X8xxhwT6Ygzvz#eg0Dr*EFIl#w|Fj_zJPFIo` zNXr|x(Si5FmA!T3Ru~dbJ|*+B;%%5hz3ZOmwttq5_5phOYiM~c6z2pJ4b&w|SSZsS zd7Y3Q@bJHTG6HTclcKTV?)TCIPQWh$sEFn@2C0tmE|(bLu~Qb3EzLMN^?$tn+-s>} z*ej{f<6j4uWIIwh;A$r&Qs3CLx0|Aw%E9AVmnX8>02^zgO|PuedI)>J476@<$~rHvz(UboV4C!fqf0TpR9IuZ<5gJqwaF z)>!A(?Fy{tHOpi1<>dp?N=y?lea z86PuL(Kuo7;ZQA(zXS-mLK6)Gg=O@LSQbhAtc!_oW|@Y3O0*nyIiGiOqt4kW=A02- zOkeCCu}rHkg+9OZ0B$_0BaRR+gD>;D2XYzcU^j(5WoQYZ>CW%@v zENW9fQ3J7d+`wx2OGGs)$O?N7YZ-_V8N2`ci}yq)>Jg(K-7>{-nxQO!>BI^^ar*0+ zoi&*VzRt0W@Q?_e?r;14T6dr9Zr&p^@I3{OHM;=f>#Y)Orct@?OJFoDOJ$zS(JUZy zc}z3E>QVLZ+vA}5`NHQ!Y4^tETlS98`w5)q(Cd1~YH5-jh&{~&U~LQ(;851rQUFVg zfHfK}s`>om#IYRrtoZh*D%yTCUQf*F(ygw_78dIDDc7G0HN;vbwX=f`mbQLx42-42 za^kbdH)gA^qma3Q)!O&gfNlQord`-(B&i z?m-WE$dr>ipgVJvu?wz=K14lU8SEzMiXAzH4st=*{4J z?Y~e0lkoOyCFg|ed6BKGX!>UZ9{dM?ra@YK!lUk-k)FD26%D`H^BJ@)i``u79+gs#kgS|R?oYa(qgFdlgLR!%uWo-HXWptRb0~r@t ze4|o7jhK01W5~h!q%&=|?|;F(vpVv@W-Wv_rm~GyjBP8#y@ws-&dsVF{?T zx}K0+#}cD$JyBLmQVA@jTBUtobR6j!Ef9Pgb!c)ns+7u3#)aLZyJo@n5Zo73t7Gdk zbG#=;&7vxZx2NxL*)g3!KV(JT?bscNOxSK<-)(S?n>cdnT2YPFOST}CvZ{=|FegVB zobJ;|WT3jWt#yG+WDhou7`>I+>QEE9VO6H_X)W9E(uo)*WjOm;8aP5VA zvD1x&J!MSU9ntdIbk_nog%z9*r;rqrDjK_%f+g>8@aA zNkLXnf>A?);jG41C*i&F3!y2PPgt~!9K2P%2YnqX5o4dXTp0U!pSG)6L*o-*g)11S zx_@Lr58w#6lEW-OjV`$x6Zz-1UCN5-SL&)oYmlZO;fB|p(TnprQb?ta=uJEukm72^ znLFGyTl=T1VMkW9l^&5-X0^~4@&o5C5XQY50hw_fDza6^8v4#r&vZSl)~ z4@i*#%DS~?o&LqN6QcI#_=I^i?nG1XzKYdKBdnM|m`%VLxN}x+(ktNo6%$heJwnl9?Vkj__x)0`RZmC)uvmW??mv&i)-SLxDqMJ(OWjexj(EgpmL3Zo zdXBOWRoXBK!$SuoOw~Uz!H0uCPlXvrtu!7zvxO6@Pch%+7@Jv%!5mGd%KV0lHS7ev z>8wmQ>SvKTXCW|8zH3=k)m}$kI~EZBpalz@nh$#txk>-C;cML!Ry+__;d`MDqYbH* z-LHt*`G}=nxrT87lWF_abrrr-*2aIK#`%!o^pN2E*T?h zXPmaFiM66FIkY}4Q&PVJ{08o2QGEOrv`G2h9TV9BWQaayZ)%6lBQ>bun!oz9Kbr~fM#Bt0zUXxNq`uhPoqi^a zuZ$&6V?SAg8{7SN@NH~mNVU6x8y-H(3gnQ|{?yqwbC1&(am6ux=UjWC)Z>ts={Fyi zR6cwNRoR#!kM@Zo{2@4PklltEShwVQ?F{=qQ;o2~Avm@+oYQhX_nam^`=(zp>;!>cOYy3Tl_z@x5oPb literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-garbage.jpg b/tests/fixtures/ingress-garbage.jpg index dc8bb9d87092afd15aad76727f330ca531f9d979..0707d54d462568c85f6f993efb42de9aaaf61611 100644 GIT binary patch literal 2048 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 diff --git a/tests/fixtures/ingress-tail-7z.jpg b/tests/fixtures/ingress-tail-7z.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9a11c962a7f90bf84ac9e46144bb00c32d7e5097 GIT binary patch literal 1168 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-tail-zip-eocd.jpg b/tests/fixtures/ingress-tail-zip-eocd.jpg new file mode 100644 index 0000000000000000000000000000000000000000..65ad664658a46722943bb03666315b242789287d GIT binary patch literal 1184 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 zV+eoUV&GwB1V$dSAcH-_pHEvZ?JA5mn-%UqZCkF%x;bHImrcF=bo~pf8|RHroi66x zYWUV<-F)4gTbZ_YQ8$5#YqoCAT=L~-_Mx>spY20YRK!}Wo$af=Ty%5h(vnAew16gj z&Dm~nJ6P_r*UO_o6OiSR4X~?+n!B{*XLcG;@uzDLS5$2?KJ%YJ_tTxaTQk1R@`pGP z*)fH2qB-d^f31t|$FyZD*cBju09_%!ZJ8<1V_mnvHf;n-{mjk+*>t@%TJl-l>S>R^ zUHSP7Z1vS;lP^DA+YR){>F6+!rF&li&G@t9bm8x;``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 zV+eoUV&GwB1V$dSAcH-_pHEvZ?JA5mn-%UqZCkF%x;bHImrcF=bo~pf8|RHroi66x zYWUV<-F)4gTbZ_YQ8$5#YqoCAT=L~-_Mx>spY20YRK!}Wo$af=Ty%5h(vnAew16gj z&Dm~nJ6P_r*UO_o6OiSR4X~?+n!B{*XLcG;@uzDLS5$2?KJ%YJ_tTxaTQk1R@`pGP z*)fH2qB-d^f31t|$FyZD*cBju09_%!ZJ8<1V_mnvHf;n-{mjk+*>t@%TJl-l>S>R^ zUHSP7Z1vS;lP^DA+YR){>F6+!rF&li&G@t9bm8x; fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url)); +const clean = await readFile(fixture('ingress-clean.jpg')); +const cleanDataUrl = `data:image/jpeg;base64,${clean.toString('base64')}`; + +// An instrumented stand-in interpreter: it records one marker file per spawn +// while it is alive, so the test can observe the true peak child count instead +// of trusting an internal counter. +async function trackingInterpreter(name, holdSeconds = '0.6') { + const dir = join(tmpdir(), `timmy-ingress-conc-${name}`); + await rm(dir, { recursive: true, force: true }); + await mkdir(join(dir, 'live'), { recursive: true }); + await mkdir(join(dir, 'seen'), { recursive: true }); + const path = join(dir, 'python3'); + await writeFile(path, [ + '#!/bin/sh', + `marker="${dir}/live/$$"`, + `touch "$marker" "${dir}/seen/$$"`, + `sleep ${holdSeconds}`, + 'rm -f "$marker"', + // Emit a valid success verdict and write the expected output file. + 'out=""', + 'while [ $# -gt 0 ]; do', + ' if [ "$1" = "--out" ]; then out="$2"; fi', + ' shift', + 'done', + `cp "${fixture('ingress-clean.jpg')}" "$out"`, + 'echo \'{"ok": true, "format": "jpeg", "width": 64, "height": 64, "bytes": 639, "metadataStripped": true}\'', + ].join('\n')); + await chmod(path, 0o755); + return { path, dir }; +} + +const withPython = async (path, run) => { + const previous = process.env.TIMMY_PYTHON; + process.env.TIMMY_PYTHON = path; + try { + return await run(); + } finally { + if (previous === undefined) delete process.env.TIMMY_PYTHON; + else process.env.TIMMY_PYTHON = previous; + } +}; + +test('the concurrency ceiling is small enough to stay inside the service memory budget', () => { + assert.equal(typeof INGRESS_MAX_CONCURRENCY, 'number'); + assert.ok(INGRESS_MAX_CONCURRENCY >= 1, 'at least one decode must be permitted'); + assert.ok(INGRESS_MAX_CONCURRENCY <= 4, + `ceiling ${INGRESS_MAX_CONCURRENCY} is too high for a 512 MiB service`); + // Worst observed single-decoder peak is ~24 MiB; the whole burst must leave + // ample headroom under MemoryMax=512M alongside the Node process itself. + assert.ok(INGRESS_MAX_CONCURRENCY * 64 * 1024 * 1024 < 512 * 1024 * 1024, + 'the ceiling must bound peak decoder memory well under 512 MiB'); +}); + +test('a burst beyond the ceiling never runs more decoder children than the ceiling', async () => { + const { path, dir } = await trackingInterpreter('peak'); + await withPython(path, async () => { + let peakLive = 0; + const sampler = setInterval(async () => { + try { + const live = await readdir(join(dir, 'live')); + peakLive = Math.max(peakLive, live.length); + } catch { /* directory races are fine */ } + }, 15); + const burst = await Promise.allSettled( + Array.from({ length: 24 }, () => validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl })), + ); + clearInterval(sampler); + const spawned = (await readdir(join(dir, 'seen'))).length; + const rejected = burst.filter((r) => r.status === 'rejected'); + + assert.ok(peakLive <= INGRESS_MAX_CONCURRENCY, + `observed ${peakLive} concurrent decoder children, ceiling is ${INGRESS_MAX_CONCURRENCY}`); + assert.ok(spawned <= INGRESS_MAX_CONCURRENCY, + `a 24-request burst spawned ${spawned} decoders; only ${INGRESS_MAX_CONCURRENCY} may run and the rest must be refused`); + assert.ok(rejected.length >= 24 - INGRESS_MAX_CONCURRENCY, + 'excess requests must be refused rather than queued'); + }); +}); + +test('over-ceiling requests fail fast with a sanitized unavailable message', async () => { + const { path } = await trackingInterpreter('failfast'); + await withPython(path, async () => { + const started = Date.now(); + const burst = await Promise.allSettled( + Array.from({ length: 12 }, () => validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl })), + ); + const rejected = burst.filter((r) => r.status === 'rejected').map((r) => r.reason); + assert.ok(rejected.length > 0, 'a 12-request burst must refuse some requests'); + for (const error of rejected) { + assert.ok(error instanceof IngressUnavailableError, + 'an over-capacity refusal is a server-capacity condition, not corrupt client input'); + assert.match(error.message, /temporarily unavailable|slow down|try again/i); + assert.doesNotMatch(error.message, /corrupt|malformed/i); + assert.doesNotMatch(error.message, /[A-Za-z0-9+/]{40,}/, 'no payload data in refusals'); + assert.ok(error.message.length < 200); + } + // Fail-fast: refusals must not wait for the in-flight decoders to finish. + assert.ok(Date.now() - started < 5000, 'refusals must be immediate, not queued behind decodes'); + }); +}); + +test('capacity is released so later requests succeed after a burst', async () => { + const { path } = await trackingInterpreter('release', '0.05'); + await withPython(path, async () => { + await Promise.allSettled( + Array.from({ length: 12 }, () => validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl })), + ); + const state = ingressConcurrencyState(); + assert.equal(state.active, 0, 'every slot must be released after the burst settles'); + const after = await validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl }); + assert.equal(after.format, 'jpeg'); + }); +}); + +test('rejected input releases its slot too', async () => { + const garbage = await readFile(fixture('ingress-garbage.jpg')); + for (let i = 0; i < INGRESS_MAX_CONCURRENCY + 3; i += 1) { + await assert.rejects(() => validateImageIngress({ + consent: true, imageDataUrl: `data:image/jpeg;base64,${garbage.toString('base64')}`, + })); + } + assert.equal(ingressConcurrencyState().active, 0, + 'a rejected request must not leak a concurrency slot'); +}); diff --git a/tests/image-dataurl.test.js b/tests/image-dataurl.test.js new file mode 100644 index 0000000..cab83ed --- /dev/null +++ b/tests/image-dataurl.test.js @@ -0,0 +1,95 @@ +// Canonical base64 data-URL grammar contract. +// +// The client always produces a canonical `data:;base64,` +// URL (canvas.toDataURL). Anything else is a hand-crafted request, so ingress +// must require the canonical grammar and a byte-exact round trip rather than +// silently repairing missing padding, embedded newlines, or non-zero trailing +// pad bits. The exact supported MIME policy is unchanged: JPEG/PNG/WebP only. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { decodeImageDataUrl, validateImageIngress } from '../src/image-ingress.js'; + +const fixture = (name) => fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url)); +const clean = await readFile(fixture('ingress-clean.jpg')); +const canonical = clean.toString('base64'); + +const ingest = (imageDataUrl) => validateImageIngress({ consent: true, imageDataUrl }); + +test('canonical base64 data URLs round-trip byte-exactly', () => { + const decoded = decodeImageDataUrl(`data:image/jpeg;base64,${canonical}`); + assert.ok(decoded, 'canonical data URL must decode'); + assert.equal(decoded.mime, 'image/jpeg'); + assert.ok(decoded.bytes.equals(clean), 'decoded bytes must equal the source bytes exactly'); +}); + +test('missing base64 padding is rejected', async () => { + const unpadded = canonical.replace(/=+$/, ''); + assert.notEqual(unpadded, canonical, 'fixture must actually require padding'); + assert.equal(decodeImageDataUrl(`data:image/jpeg;base64,${unpadded}`), null); + await assert.rejects(() => ingest(`data:image/jpeg;base64,${unpadded}`), /not a supported image/i); +}); + +test('excess base64 padding is rejected', async () => { + assert.equal(decodeImageDataUrl(`data:image/jpeg;base64,${canonical}===`), null); + await assert.rejects(() => ingest(`data:image/jpeg;base64,${canonical}====`), /not a supported image/i); +}); + +test('CR and LF inside the base64 payload are rejected', async () => { + const withCrlf = `${canonical.slice(0, 40)}\r\n${canonical.slice(40)}`; + const withLf = `${canonical.slice(0, 40)}\n${canonical.slice(40)}`; + const withCr = `${canonical.slice(0, 40)}\r${canonical.slice(40)}`; + for (const payload of [withCrlf, withLf, withCr]) { + assert.equal(decodeImageDataUrl(`data:image/jpeg;base64,${payload}`), null); + await assert.rejects(() => ingest(`data:image/jpeg;base64,${payload}`), /not a supported image/i); + } +}); + +test('non-canonical encodings are rejected: non-zero trailing pad bits, whitespace, URL-safe alphabet', async () => { + const nonZeroPadBits = `${canonical.slice(0, -2)}/=`; + const withSpace = `${canonical.slice(0, 20)} ${canonical.slice(20)}`; + const urlSafe = canonical.replace(/\+/g, '-').replace(/\//g, '_'); + for (const payload of [nonZeroPadBits, withSpace]) { + assert.equal(decodeImageDataUrl(`data:image/jpeg;base64,${payload}`), null); + await assert.rejects(() => ingest(`data:image/jpeg;base64,${payload}`), /not a supported image/i); + } + if (urlSafe !== canonical) { + assert.equal(decodeImageDataUrl(`data:image/jpeg;base64,${urlSafe}`), null); + } +}); + +test('the supported MIME policy is exact and unchanged', async () => { + for (const mime of ['image/jpeg', 'image/png', 'image/webp']) { + assert.ok(decodeImageDataUrl(`data:${mime};base64,${canonical}`), `${mime} must remain supported`); + } + for (const mime of ['image/gif', 'image/svg+xml', 'text/html', 'application/octet-stream', '']) { + assert.equal(decodeImageDataUrl(`data:${mime};base64,${canonical}`), null, `${mime} must not be supported`); + await assert.rejects(() => ingest(`data:${mime};base64,${canonical}`), /not a supported image/i); + } +}); + +test('malformed data-URL envelopes are rejected without repair', async () => { + const malformed = [ + `data:image/jpeg,${canonical}`, // no base64 token + `data:image/jpeg;base64;${canonical}`, // wrong separator + `DATA:image/jpeg;base64,${canonical}`, // scheme casing is not canonical here + `data:image/jpeg;base64,`, // empty payload + ` data:image/jpeg;base64,${canonical}`, // leading whitespace + `data:image/jpeg;base64,${canonical} `, // trailing whitespace + `data:image/jpeg;charset=utf-8;base64,${canonical}`, // extra parameters + canonical, // bare base64, no envelope + ]; + for (const value of malformed) { + assert.equal(decodeImageDataUrl(value), null, `must reject: ${value.slice(0, 42)}`); + await assert.rejects(() => ingest(value), /not a supported image/i); + } +}); + +test('a canonical supported data URL still completes ingress end to end', async () => { + const result = await ingest(`data:image/jpeg;base64,${canonical}`); + assert.equal(result.format, 'jpeg'); + assert.equal(result.mime, 'image/jpeg'); + assert.match(result.imageDataUrl, /^data:image\/jpeg;base64,[A-Za-z0-9+/]+={0,2}$/); +}); diff --git a/tests/image-ingress.test.js b/tests/image-ingress.test.js index 8a86842..0e6c1dd 100644 --- a/tests/image-ingress.test.js +++ b/tests/image-ingress.test.js @@ -65,7 +65,13 @@ test('malformed and truncated images fail closed with sanitized errors', async ( return true; }); } - await assert.rejects(() => ingestFixture('ingress-truncated.jpg'), /corrupt or malformed/i); + // A truncated JPEG has no EOI, so canonical container parsing rejects it + // before any decode or subprocess spawn. Either sanitized ingress error is + // acceptable; leaking anything else is not. + await assert.rejects(() => ingestFixture('ingress-truncated.jpg'), (error) => { + assert.match(error.message, /not a supported image|corrupt or malformed/i); + return true; + }); }); test('decompression bombs and oversized dimensions fail before provider work', async () => { diff --git a/tests/image-polyglot.test.js b/tests/image-polyglot.test.js new file mode 100644 index 0000000..0946815 --- /dev/null +++ b/tests/image-polyglot.test.js @@ -0,0 +1,86 @@ +// Container/polyglot rejection contract. +// +// The requirement is malformed/polyglot REJECTION, not "the re-encoder happens +// to drop the tail". Rejection must come from canonical parsing of the image +// container — the declared structure must consume exactly the supplied bytes — +// so it cannot be evaded by changing keyword casing or archive flavour, and it +// must not fire on arbitrary compressed bytes that merely contain those +// sequences inside legal image structure. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { validateImageIngress } from '../src/image-ingress.js'; +import { analyzePhoto } from '../src/vision-service.js'; + +const fixture = (name) => fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url)); +const readFixture = (name) => readFile(fixture(name)); +const dataUrl = (bytes, mime = 'image/jpeg') => `data:${mime};base64,${bytes.toString('base64')}`; + +const TRAILING_DATA_FIXTURES = [ + ['ingress-tail-upper-script.jpg', 'uppercase