Scope: an authorized security assessment of this service, run entirely against local targets (native macOS build and the local Docker image) on the developer’s own machine — not a third-party or production system. Companion to Testing & Validation, which covers functional correctness; this page covers adversarial robustness — what happens when inputs and traffic patterns are deliberately hostile rather than merely varied.

Final results (current state)

Seven phases, five findings worth acting on. The critical one has already been fixed and verified; the rest are real but lower-severity or already mitigated by the service’s architecture. What held up well: the detector thread-safety fix (zero errors across all adversarial concurrency levels in Phase 3), the app-level input validation contract (9/11 multipart edge cases in Phase 4 matched documented behavior exactly, including magic-byte format checking), the /v1/health//v1/ready endpoints’ isolation from worker-pool load, and the non-root/no-secrets/minimal-runtime-package Docker posture. No crashes, no hangs achieved at production-realistic scale, no information disclosure found anywhere.

Phase 1: Unhandled-exception hang — code-level gap confirmed, not reproduced by attack

Method: ThreadPool::workerLoop() (src/ThreadPool.h:77-86) catches (...) around each job and silently drops the result if one throws — the captured Drogon response callback is destroyed without ever firing, and no request/idle timeout is configured anywhere in main.cpp. Neither EmbedController nor CompareController wraps decodeAndValidate/detectAndEmbed/cosineSimilarity in their own try/catch. On paper, a crafted-but-decodable image that throws a cv::Exception/Ort::Exception mid-pipeline should leave the client connection hanging indefinitely with no response. We tried to actually trigger that throw, systematically, across four vector families (~40 crafted images total), all sent sequentially against the native build with server stderr tailed in parallel:
Images at/near the 20px–4096px validation boundary designed to make cv::resize in detectFaces() (triggered when the long side exceeds 640px) compute a near-zero short-side dimension: 4096×20, 20×4096, 4095×21, 4096×4095, 4000×22, etc.Result: all handled cleanly — either 422 no_face_detected in ~0.01s (random noise, no face present, as expected) or 413 for the two combinations that exceeded the 12MB body cap. cv::resize did not throw for any tested ratio; the short side never actually rounds to zero across the accepted dimension range given the fixed 640px cap.
Conclusion: the code-level gap is real and unchanged by this testing — there is genuinely no try/catch around the inference pipeline and genuinely no request timeout, so if something throws mid-pipeline, the hang-forever behavior described above would occur. What we could not do, despite a systematic multi-vector attempt, is find an input that actually causes that throw with the current OpenCV 4.10/ONNX Runtime 1.19.2 versions and this specific pipeline. This should be read as “not exploitable with the vectors we tried,” not “not a bug” — the try/catch-and-timeout gap is exactly the kind of defense-in-depth that protects against inputs nobody has thought to try yet, or against a future dependency upgrade introducing a new throw path. Recommended fix stands regardless of non-reproduction (see Remediation).

Phase 2: Decompression-bomb quantification — confirmed, high amplification

Method: ImageUtils::decodeAndValidate (src/ImageUtils.cpp:6-42) checks raw upload size (10MB) before cv::imdecode, but the 4096px dimension check runs after — so a small, low-entropy PNG fully decodes (and allocates its full bitmap) before any dimension rejection can happen. Two payloads, both solid-color (gray) so they compress to a tiny file despite large logical dimensions:
  • 4096×4096 — exceeds the limit, rejected after decode (measures decode-then-reject cost)
  • 4095×4095 — the largest image the service will actually accept and process further (measures the real, sustained attack surface)
RSS of the face_engine process was polled every 20ms via ps -o rss= throughout each request.
*Measured immediately after the 4096×4096 case above, so this baseline was already elevated — see the concurrency sweep below for a cleaner picture of accepted-image cost in isolation.Both cases return 422 (image_dimensions_exceed_limit for the 4096 case; no_face_detected for the 4095 case — a solid gray image has no face, so detection legitimately finds nothing even though the full decode-and-detect pipeline still runs). The 4096 case’s larger delta is expected: it briefly holds both the full decode buffer and momentarily exists before the post-decode check frees it.
Conclusion: the post-decode dimension check gap is a confirmed, measurable finding — not just a code-review observation. ~58KB on disk reliably produces tens-to-hundreds of MB of transient allocation depending on concurrency, and the growth doesn’t self-correct within the timeframe tested. Recommended fix: reject on decoded dimensions before or during decode rather than after — e.g. a header-only peek (cv::haveImageReader plus manually parsing width/height from the format header) before calling cv::imdecode, so oversized images are rejected without ever being fully decoded into memory.

Phase 3: Adversarial flood — confirms Phase 1 & Phase 2 findings under sustained load

Method: rebuilt the benign load-test methodology from Testing & Validation §6 (concurrency sweep 1/2/4/8/16/24/32, 12-second sustained windows, RPM/p50/p95/error tracking), but adversarially — each request randomly picks between the Phase 2 decompression bomb, a Phase 1 extreme-aspect-ratio payload, and a normal control photo, instead of only normal photos. RSS polled every 100ms throughout each window, plus again 30 seconds after the window closes (recovery check), plus a mid-flood GET /v1/ready probe to test I/O-thread responsiveness independent of worker-pool load. Three findings:
  1. Zero errors at every concurrency level, including under adversarial payloads. This confirms the earlier detector thread-safety fix (see Architecture → InferenceEngine) holds under hostile, not just benign, concurrent traffic — worth stating explicitly since the pre-fix version of this service produced up to 29 errors/timeouts per 12s window under purely benign concurrent load.
  2. RSS grows large but does partially recover — e.g. at N=8, RSS dropped from a peak of 980MB to 382MB within 30 seconds idle; at N=16 and N=32, roughly 200MB was reclaimed. This is a more complete picture than Phase 2 alone suggested (that test didn’t wait long enough to observe recovery) — the allocator does return memory to the OS, just slowly and incompletely, not instantly or fully. RSS never returns anywhere close to the 32.5MB cold-start baseline within the timeframes tested here, so sustained adversarial traffic still produces a persistently elevated memory footprint, just not an unbounded runaway leak.
  3. /v1/ready stayed fully responsive (200, 1–3ms) at every concurrency level, including N=32. This directly answers the I/O-thread- starvation question raised in the ground truth: at least up to 32 concurrent adversarial requests on this 10-core host, HTTP_IO_THREADS=2 was not saturated enough to delay the lightweight health-check path, which — as established in Architecture — bypasses the worker pool entirely and only competes for I/O-thread time.
Raw RPM here (up to 2,518) is not directly comparable to the benign baseline in testing-report.mdx (peak 1,944 RPM) — this test’s payload mix is two-thirds fast-reject images (bomb/extreme-aspect payloads both return in ~10–30ms since they never reach the expensive align+embed stage, having no detectable face) versus the benign baseline’s all-real-photo mix, which always pays the full pipeline cost. Higher RPM here reflects a cheaper average payload, not a faster server.

Phase 4: Multipart edge-case sweep — two real findings, rest matches contract cleanly

Method: hand-built raw multipart bodies over raw sockets/http.client (not requests’ own encoder, since several cases need to break the format in ways the library won’t allow) against POST /v1/embed, diffed against the documented error contract.
Nine of eleven tested cases behaved exactly as documented — no crashes, no hangs, no info leaks, no surprises.
Conclusion: the app-level error contract (ImageUtils, both controllers) is solid — nine of eleven cases matched documented behavior exactly, including the security-relevant ones (magic-byte validation, first-match field handling). The two real findings are both at the HTTP connection-framing layer (Drogon itself, not app code): a genuine 60-second slow-connection hold that doesn’t achieve DoS at small scale thanks to Drogon’s async I/O model, and standard request-framing behavior whose risk is entirely about what sits in front of this service, not the service itself.

Phase 5: Dependency / CVE review — one critical, directly reachable finding

Method: exact installed versions pulled from the actual built image (shell + dpkg confirmed present in the ubuntu:24.04-based runtime image):
Cross-referenced these plus the three source-built pins (OpenCV 4.10.0, Drogon v1.9.7, ONNX Runtime 1.19.2, from the Dockerfile ARGs) against OSV.dev, GitHub’s advisory database, and NVD directly (GitHub’s own per-repo advisory endpoints returned nothing for opencv/opencv, drogonframework/drogon, or microsoft/onnxruntime — NVD keyword search was the source that actually found something).

CVE-2025-53644 — CVSS 9.8 Critical — directly reachable

OpenCV 4.10.0 and 4.11.0 (this project is pinned to exactly 4.10.0) have an uninitialized stack pointer in cv::detail::Jpeg2KOpjDecoderBase::readHeader (OpenJPEG-based JPEG 2000 decoder) that, on a malformed .jp2 file, leads to an attacker-controlled arbitrary-length write at an attacker-controlled valueicc_profile_len is fully attacker-controlled per the upstream proof-of-concept. Fixed in OpenCV 4.12.0 (disclosed via coordinated process by GitHub Security Lab, GHSL-2025-057, fix commit).Reachability: confirmed, not theoretical. cv::imdecode() dispatches by magic bytes, not by client-supplied filename/Content-Type (already established as a validation strength elsewhere in this report) — so any file with valid JP2 magic bytes reaches this decoder regardless of what the uploader claims it is. Verified directly against the running Docker image (face-engine:baked, OpenCV 4.10.0): generated a benign (non-malicious) valid .jp2 file and POSTed it to /v1/embed — the service decoded it successfully (422 no_face_detected, not undecodable_image — confirming imdecode succeeded) and the container remained alive. This confirms JPEG2000/OpenJPEG support is compiled into this build’s imgcodecs module (the Dockerfile doesn’t explicitly disable it, so OpenCV’s default-bundled 3rdparty OpenJPEG was built), and that it’s reachable through the public, unauthenticated upload endpoint with no format allow-list to stop it.Intentionally not attempted: constructing an actual crafted malicious .jp2 to trigger the memory-corruption write itself. The upstream advisory already includes a working PoC and a confirmed fix, so re-deriving exploitation here adds no evidentiary value, and doing so against a container also used for other tests in this assessment risked an unpredictable crash for no additional benefit. Reachability confirmation (above) is sufficient to treat this as critical and requiring immediate remediation.Fix: bump OPENCV_VERSION in the Dockerfile from 4.10.0 to 4.12.0 or later and rebuild. This is a one-line change; see Remediation.
Fixed and verified. OPENCV_VERSION in the Dockerfile is now 4.12.0. Rebuilt the image and confirmed libopencv_core.so.4.12.0 is the version actually linked in the runtime container (not just the build arg — checked the real .so file inside the built image). Re-ran functional validation against the patched image: /v1/ready → 200, /v1/embed returns a correct embedding, /v1/compare on identical images returns similarity: 1.0, match: true — the version bump didn’t regress anything. The reachability path described above (arbitrary .jp2 uploads hitting imgcodecs) still exists structurally — this service still accepts any image format OpenCV can decode, by design — but the specific vulnerable code this CVE describes no longer ships in the image.
Everything else reviewed, with reachability assessed rather than just listed:
UBUNTU-CVE-2026-13595, -27456, -53612, -53613, -53614, -53615 — all in libblkid (partition/filesystem-type probing) or the mount(8) SUID binary (local privilege escalation via TOCTOU). This container installs only the libuuid1 runtime library (UUID generation, used by Drogon) — not libblkid1, not the mount binary, not util-linux proper. None of the vulnerable code paths are present in this image at all. Not reachable.
Conclusion: one critical, confirmed-reachable finding (CVE-2025-53644) dominates this phase — everything else reviewed is either definitively not reachable given this container’s actual configuration, or has no known advisory at all. The OpenCV version bump is the single highest-priority action item to come out of this entire assessment.

Phase 6: Docker image scan (trivy) — confirms Phase 5, plus a meta-finding about trivy itself

Method: brew install trivy (0.73.0), then trivy image (triage + full) against face-engine:baked and trivy config . against the Dockerfile.
Full unfiltered scan: 18 total (12 MEDIUM, 6 LOW), all in Ubuntu base-image OS packages — libmount1, libp11-kit0, libsmartcols1, libsystemd0, libudev1, login, mount, passwd, util-linux, zlib1g, libuuid1. The libuuid1/zlib1g entries corroborate Phase 5’s manual OSV lookup exactly (same CVE IDs, same reachability conclusion: not applicable, since this container never invokes mount, login, or loop-device setup). The rest (libp11-kit0, libsystemd0, libudev1, login, passwd, mount, libsmartcols1, libmount1) are base-image packages this Dockerfile never explicitly installs — they ship with ubuntu:24.04 regardless. None are reachable through this service’s actual functionality (no shell execution, no local multi-user context, no systemd/mount usage anywhere in src/).
Conclusion: trivy adds real value (found nothing to contradict Phase 5, corroborated the libuuid1/zlib1g findings independently, and confirmed the Dockerfile misconfiguration count precisely) but cannot replace the manual review — it is architecturally blind to any dependency this project builds from source rather than installs as a package. Any future scanning workflow for this project should treat trivy as covering “the OS layer” only, with OpenCV/Drogon/ONNX Runtime version tracking requiring a manual or custom process.

Phase 7: No-auth/TLS/rate-limit confirmation (light touch)

This is a documented design choice, not a surprise finding, so this phase is confirmation rather than deep testing. Confirmed via curl -v: GET /v1/health, GET /v1/ready, and POST /v1/embed all accept plain HTTP with zero authentication headers and return normal 200 responses — no TLS, no API key, no session, no rate limiting anywhere in this container. This matches what’s already documented:
“the service is meant to sit behind a gateway that handles routing/TLS/auth” — api-reference/introduction.mdx
“Rate limiting — belongs at the load balancer/gateway, not baked into this service” — deployment.mdx
Residual risk, framed concretely rather than abstractly: every finding in Phases 1–5 of this report assumes an attacker can reach /v1/embed or /v1/compare directly. That’s exactly what “no auth, no gateway” means in practice — if this container is ever deployed with its port exposed directly to untrusted traffic (a misconfiguration, not the documented intent), every finding above becomes immediately exploitable by anyone, not just an authorized tester on localhost. This is the strongest argument for treating the gateway requirement as load-bearing, not optional — especially given Phase 5’s critical finding is trivially reachable by design (any file upload triggers imdecode).

Remediation

Ordered by priority, not by discovery order:
1

✅ Done — Bump OPENCV_VERSION to >= 4.12.0 (critical, do first)

One-line change in Dockerfile: ARG OPENCV_VERSION=4.10.04.12.0, then rebuild. Closes CVE-2025-53644 (CVSS 9.8), confirmed reachable through the public upload endpoint. See Phase 5. Applied and verified: rebuilt, confirmed libopencv_core.so.4.12.0 is the version actually linked in the container, and re-ran functional validation (/v1/ready, /v1/embed, /v1/compare) against the patched image with no regressions.
2

Reject on decoded dimensions before/during decode, not after

ImageUtils::decodeAndValidate (src/ImageUtils.cpp) currently checks kMaxDimension after cv::imdecode has already allocated the full bitmap. Use a header-only peek (parse width/height from the format header, or cv::imdecode with a size-limiting flag if available in the OpenCV version landed above) to reject oversized images before the expensive allocation happens. Closes the amplification finding from Phase 2/Phase 3.
3

Wrap controller pipeline calls in try/catch; add a request timeout

Neither EmbedController nor CompareController catches exceptions from decodeAndValidate/detectAndEmbed/cosineSimilarity — add a try/catch mapping any exception to a proper 500 JSON error (matching the existing error-response shape) rather than relying on ThreadPool’s catch-all, which silently drops the callback. Separately, configure a Drogon request/idle timeout in main.cpp (currently unset) so even an unanticipated hang self-resolves instead of holding a connection indefinitely. Addresses the code-level gap from Phase 1 and reduces the ~60s window found in Phase 4.
4

Add a bounded queue depth to ThreadPool

ThreadPool::enqueue() (src/ThreadPool.h) has no cap and no rejection path. Add a max-depth check (pendingJobs() already exists as the read) that rejects new work with a 503/429 once the queue is saturated, rather than accepting unbounded work that only ever grows memory usage under sustained load (see Phase 3).
5

Add a HEALTHCHECK instruction to the Dockerfile

Low effort, low severity, but free — HEALTHCHECK --interval=30s CMD curl -f http://localhost:8080/v1/ready || exit 1 (or equivalent) lets container orchestrators detect an unhealthy instance without depending on an external readiness probe being configured correctly. See Phase 6.
6

Pin runtime apt package versions in the Dockerfile

libjsoncpp25, libssl3t64, libuuid1, zlib1g are currently installed without version pins, so the exact version baked into any given image build depends on whatever’s current in the Ubuntu 24.04 apt repo that day. Pinning (libssl3t64=3.0.13-0ubuntu3.12, etc.) makes builds reproducible and makes future CVE reviews exact rather than approximate — trades that for needing to manually bump pins when intentionally taking a security update. See Phase 5.
None of these require an architecture change — all six are scoped, independent fixes to the existing codebase.