Security Model & Hardening¶
This document describes Iris's deployment trust model, the authentication boundary, the per-request defensive controls, and the container-level hardening. It is written as a threat model followed by the concrete controls that address each threat, and it is honest about the residual assumptions the design makes.
Everything here reflects the actual code. Where a claim maps to a specific mechanism, the source file is named so it can be audited.
1. Deployment trust model¶
Iris is a single-GPU appliance designed to run on one host, behind a reverse proxy that already does session authentication for the rest of the estate. It is not designed to be exposed directly to the internet, and the code makes that assumption explicit rather than implicit.
flowchart LR
U[Operator browser] -->|TLS + SSO cookie| N[nginx vhost]
subgraph host [Single host]
N -->|auth_request → SSO service| S[(cookie-SSO)]
N -->|"proxy_pass 127.0.0.1:8120<br/> + injected X-Email header"| A[Iris / uvicorn]
A --> DB[(SQLite WAL)]
A --> M[/data: recordings, faces, segments/]
end
T[SSH tunnel / CLI] -.->|"Bearer API key<br/>(bypasses nginx)"| A
Two properties anchor the model:
-
Loopback bind. The application binds to
127.0.0.1:8120(Settings.hostinapp/config.py), and Docker publishes the port as127.0.0.1:8120:8120(docker-compose.yml). The container's internaluvicorn --host 0.0.0.0(Dockerfile) is bound to all interfaces inside the container's network namespace only; the published mapping pins it to host loopback. The service therefore has no presence on the public IP — every external request must traverse nginx. -
nginx is the authentication boundary. The nginx vhost terminates TLS, runs an
auth_requestsubrequest against the cookie-SSO service, and on success injects a trusted identity header (X-Email, configurable viaSSO_HEADER) into the upstream request. Iris treats the presence of that header as proof of authentication (require_userinapp/auth.py).
The header-injection / override requirement (anti-spoof)¶
Because the app trusts the SSO header, that header must be un-spoofable from the client side. The nginx vhost is responsible for this: it must set the header from the auth subrequest result and overwrite any client-supplied value, e.g.
auth_request_set $sso_email $upstream_http_x_auth_email;
proxy_set_header X-Email $sso_email; # overrides any client X-Email
proxy_set_header replaces whatever the client sent. A request arriving at nginx
with a forged X-Email has that header discarded and rewritten with the value
nginx derived from the validated session. This is documented in .env.example
as a hard requirement of the deployment, and it is the single most important
operator-side control.
Residual assumption (stated plainly). Iris delegates authentication and session validation entirely to the SSO gateway. It does not verify the session itself, does not check group membership, and has no per-user authorization model — any authenticated principal can use the full API. If the nginx vhost is misconfigured (header not overridden) or bypassed (something else can reach
127.0.0.1:8120), the trust model collapses to "whoever can reach the socket is an admin." The loopback bind, the API-key fallback, and the fail-closed default below are the defenses that keep that failure mode narrow.
2. Authentication: fail-closed + constant-time key¶
The auth dependency (app/auth.py, require_user / aliased require_auth)
accepts two credentials, in priority order:
- Trusted SSO header (
X-Email). If present → authenticatedPrincipalwithvia="sso". The display name comes fromX-User(sso_user_header). Authorization: Bearer <API_KEY>— an optional credential for SSH-tunnel / CLI use that legitimately bypasses nginx (hitting loopback directly).
If neither is present:
- When
AUTH_REQUIRED=true(the default —Settings.auth_required), the request is rejected with401. This is the fail-closed posture: behind nginx the header is always injected, so the only callers that get a 401 are direct loopback/tunnel callers with no key. - When
AUTH_REQUIRED=false, the dependency degrades to an anonymousPrincipalso the API is usable out of the box for local development with no proxy and no key..env.exampledocuments that this is for local dev only.
Constant-time key comparison. The API key is compared with
hmac.compare_digest(token, settings.api_key) (app/auth.py:60), not ==. This
removes the timing side-channel that a naive byte-by-byte string compare would
leak, so an attacker on the loopback path cannot recover the key one byte at a
time from response-time differences. The key is only honored if both
settings.api_key is set and a bearer token was supplied; an unset key disables
API-key auth entirely. .env.example recommends generating it with
openssl rand -hex 32.
Router coverage. Every API router declares the auth dependency, either at the
router level (dependencies=[Depends(require_auth)] on cameras, identities,
people, face-groups) or per-endpoint (live.py, events.py, system.py). The
only deliberately unauthenticated route is GET /health (app/main.py), a
liveness + GPU/worker snapshot used by an external monitoring layer; it exposes no
camera data, media, or identity information.
3. Per-request defensive controls¶
These are application-layer mitigations against the specific ways a hostile (or merely malformed) input could be abused, even by an authenticated caller.
3.1 RTSP credential masking¶
Camera RTSP URLs routinely embed user:pass@host credentials. Those credentials
never leave the server in an API response. _mask_rtsp()
(app/api/cameras.py:134) rewrites rtsp://user:pass@host:554/s to
rtsp://***@host:554/s — the host is kept (operators need to identify the
camera) but the userinfo is redacted. _to_out() applies this on every camera
serialization, so list and detail responses are masked uniformly.
The worker reads the raw URL straight from the DB, so masking the API
response does not affect capture. On update, the API treats a blank or masked URL
as "keep current" (app/api/cameras.py:241–247), so the UI echoing back the
redacted value on an unrelated edit cannot accidentally wipe the real
credentials. ffmpeg command lines that may embed credentials are only logged at
DEBUG (app/recording/hls.py:146).
3.2 ffmpeg protocol whitelist + source scheme validation (anti-SSRF)¶
A camera URL is an attacker-controlled string handed to ffmpeg, which natively
speaks file://, concat:, gopher://, data: and more. Two layers prevent
it being turned into an SSRF or local-file-read primitive:
- Scheme validation at the API boundary.
_check_rtsp_scheme()(app/schemas.py) restricts the source URL to a tight streaming allowlist:rtsp,rtsps,rtmp,rtmps,http,https— on both create and update. Everything else (file:///etc/passwd,gopher://,data:,concat:,pipe:) is refused before it is ever stored. - Per-type protocol whitelist at the ffmpeg invocation. The single helper
app/recording/ffmpeg_source.pyowns the protocol specifics for every input site (rolling segmenterapp/recording/segmenter.py, on-demand HLS transcoderapp/recording/hls.py, subtitle audio pullerapp/subtitles/session.py, and the worker's OpenCV capture). Each source type gets the tightest-protocol_whitelistfor its family (rtsp:rtsp,rtsps,rtp,rtcp,udp,tcp,tls,crypto; rtmp:rtmp,rtmps,tcp,tls,crypto; http:http,https,tcp,tls,crypto; hls:http,https,tcp,tls,crypto,hls).file:/pipe:/concat:are never in any whitelist, so the demuxer cannot read a local file or pipe regardless of source type.
Residual SSRF tradeoff (multi-protocol sources). Allowing http(s) as a
camera source means an authenticated operator can point ffmpeg at an internal
HTTP endpoint (e.g. http://169.254.169.254/...). This is an accepted tradeoff:
camera CRUD is restricted to authenticated/trusted operators, the scheme
allowlist stays tight (no file:/gopher:/data:), and the per-type
-protocol_whitelist still forbids file:/pipe: — so the worst case is an
HTTP fetch that ffmpeg then tries to decode as media; the response body can
never be surfaced as a local file read, and ffmpeg will simply fail to demux a
non-media response.
This is defense in depth: the input is validated and the executor is constrained.
3.3 Upload decompression-bomb guard¶
Manual face enrollment accepts an uploaded image (app/api/people.py). A small
file can decode into an enormous raster and OOM the worker, so the upload is
bounded on both dimensions:
- Bytes: rejected over
MAX_IMAGE_BYTES = 8 MiB(413), checked after read and before decode (people.py:333). nginx also enforcesclient_max_body_size; this is the defensive in-app duplicate. - Pixels: after
cv2.imdecode,_decode_image()rejects anything whose decodedheight × widthexceedsMAX_IMAGE_PIXELS = 50 MP(413,people.py:126). This is the actual decompression-bomb control — it caps the decoded raster, not just the compressed file.
Content-type is additionally checked against an allow-list (415).
3.4 Path-traversal containment¶
The DB stores relative media paths (clips, thumbnails, face samples, identity
crops). When a route resolves one of these to an absolute path to stream or
delete it, the resolved path is contained to the data root with
os.path.commonpath:
if os.path.commonpath([candidate, data_root]) != data_root:
raise HTTPException(status_code=400, detail="Invalid stored path")
This guard appears in every resolver: events._abs_data_path
(app/api/events.py:70), identities crop resolution
(app/api/identities.py:212), and people._resolve_image_path
(app/api/people.py:539). A stored path laden with .. (or a maliciously
crafted absolute path) resolves to something outside data_root, fails the
containment check, and is refused — so neither the clip-streaming nor the
recursive-delete paths can be coerced into reading or unlinking files outside the
data directory. Note that people._resolve_image_path deliberately no longer
trusts absolute stored paths verbatim; stored paths are always treated as
relative to the data dir.
3.5 Bounded request bodies¶
All free-form and list inputs carry explicit max_length bounds so a single
request cannot allocate unbounded memory:
- Camera
name≤ 200,rtsp_url≤ 2000 (app/schemas.py). - Identity
name≤ 200,notes≤ 2000; mergesource_ids≤ 1000 entries; splitsighting_ids≤ 5000 entries (app/reid/schemas.py).
These caps bound the bulk operations (merge/split/delete) that would otherwise accept arbitrarily large id-lists.
3.6 Non-pickle vector (de)serialization¶
Face and appearance embeddings are stored as BLOBs in SQLite. They are
serialized and read back with numpy.frombuffer over little-endian float32
bytes (<f4), never with pickle. See serialize_vector /
deserialize_vector (app/reid/gallery.py:45–58), and the matching
np.frombuffer(blob, dtype="<f4") reads in app/reid/maintenance.py,
app/faces/index.py, and app/api/people.py. The deserializers validate the
decoded length against the expected embedding dimension (512) and reject
mismatches.
Because no pickle (or other object-graph deserializer) is ever invoked on
stored vectors, a tampered BLOB cannot trigger arbitrary code execution on read —
it can at worst produce a wrong-length array, which is rejected. This closes the
classic "deserialization RCE" path that pickled embeddings would open.
4. Container hardening¶
The compose service (docker-compose.yml) is locked down so that an
in-container write primitive or RCE has minimal blast radius, and so the VMS can
never destabilize co-hosted workloads on the shared host.
| Control | Setting | Effect |
|---|---|---|
| Non-root | user: "1000:1000" |
Runs as the unprivileged host uid (deploy), not root. An in-container compromise cannot tamper with host-owned files or processes. The GPU still works because /dev/nvidia* device nodes are world-accessible. |
| Drop all capabilities | cap_drop: [ALL] |
The workload needs no Linux capabilities; none are granted. |
| No privilege escalation | security_opt: ["no-new-privileges:true"] |
No child process can gain privileges via setuid/setgid binaries. |
| Read-only app mount | ./app:/app/app:ro |
The application source is mounted read-only — the container can never rewrite its own code, so an RCE can't persist by patching the codebase. (./data and ./models are writable, as required for media/DB/model packs.) |
| Memory cap | mem_limit: 6g, memswap_limit: 6g |
Hard RAM ceiling. If Iris exceeds it, a cgroup-scoped OOM kills a camera worker (which restarts) — it cannot trigger a host-wide OOM. |
| OOM victim preference | oom_score_adj: 800 |
If host-wide memory pressure ever does occur, the kernel prefers to kill Iris over the protected co-hosted VM (scored -1000). |
The OOM isolation is a deliberate availability control on a shared host: a runaway in Iris degrades to a restarting camera worker, never to a global OOM that would take down a neighbouring production VM.
Build-time supply-chain hygiene: the Dockerfile upgrades setuptools>=70.0.0
before installing, which pins out CVE-2022-40897 / CVE-2024-6345 in the
setuptools (59.6.0) shipped by the CUDA base image. This is build-time only.
5. Threat model → controls summary¶
| Threat | Control(s) | Where |
|---|---|---|
| Direct internet exposure of the API | Loopback bind; port published only to 127.0.0.1 |
config.py, docker-compose.yml |
| Forged identity header to impersonate a user | nginx overrides client X-Email via proxy_set_header; app trusts header only because it's loopback-only behind nginx |
auth.py, .env.example |
| Unauthenticated access | Fail-closed AUTH_REQUIRED=true; auth dependency on every router; only /health is open |
auth.py, main.py, all api/*.py |
| API-key recovery via timing | hmac.compare_digest constant-time compare |
auth.py:60 |
| Source-URL credential disclosure (any scheme) | _mask_rtsp redacts userinfo in all responses for rtsp/rtmp/http URLs; ffmpeg cmd logged at DEBUG only |
api/cameras.py |
| SSRF / local-file read via camera URL | Tight scheme allowlist (rtsp,rtsps,rtmp,rtmps,http,https) + per-type ffmpeg -protocol_whitelist (no file:/pipe:); residual http(s) SSRF accepted (operators authenticated) |
schemas.py, recording/ffmpeg_source.py, recording/segmenter.py, recording/hls.py, subtitles/session.py |
| Decompression-bomb OOM on upload | 8 MiB byte cap + 50 MP decoded-pixel cap + content-type allow-list | api/people.py |
| Path traversal via stored media paths | os.path.commonpath containment in every resolver; relative-only stored paths |
events.py, identities.py, people.py |
| Memory exhaustion via large request bodies | max_length bounds on strings and id-lists |
schemas.py, reid/schemas.py |
| Deserialization RCE from stored vectors | numpy.frombuffer float32, never pickle; length-validated |
reid/gallery.py et al. |
| In-container RCE tampering with host / persisting | Non-root uid, cap_drop: ALL, no-new-privileges, read-only app mount |
docker-compose.yml |
| VMS OOM-killing a co-hosted production VM | cgroup mem_limit/memswap_limit + oom_score_adj |
docker-compose.yml |
6. Residual assumptions & non-goals¶
These are deliberately out of scope; recording them is part of the model.
- SSO gateway is trusted. Authentication, session lifetime, and logout are the gateway's responsibility. Iris has no login UI, no session store, and no per-user authorization — every authenticated principal has full API access.
- No per-user authz / audit-of-actor. The
Principalcarries an email for display, but the API does not gate operations by identity or role. - Local-dev escape hatch.
AUTH_REQUIRED=falsedisables auth entirely and must never be used outside a trusted local machine. - TLS is terminated at nginx. The loopback hop between nginx and uvicorn is plaintext HTTP, which is acceptable because it never leaves the host.
- Privacy/biometric considerations (face embeddings are biometric data) are a
governance concern for the operator, not enforced by code. The repo ships no
secrets and no biometric data;
.env.exampledocuments the auth and key configuration only.
The repository ships no secrets and no real RTSP credentials. The defaults are
the secure ones: loopback bind, AUTH_REQUIRED=true, masked URLs, hardened
container.