Перейти к содержанию

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:

  1. Loopback bind. The application binds to 127.0.0.1:8120 (Settings.host in app/config.py), and Docker publishes the port as 127.0.0.1:8120:8120 (docker-compose.yml). The container's internal uvicorn --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.

  2. nginx is the authentication boundary. The nginx vhost terminates TLS, runs an auth_request subrequest against the cookie-SSO service, and on success injects a trusted identity header (X-Email, configurable via SSO_HEADER) into the upstream request. Iris treats the presence of that header as proof of authentication (require_user in app/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:

  1. Trusted SSO header (X-Email). If present → authenticated Principal with via="sso". The display name comes from X-User (sso_user_header).
  2. 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 with 401. 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 anonymous Principal so the API is usable out of the box for local development with no proxy and no key. .env.example documents 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.py owns the protocol specifics for every input site (rolling segmenter app/recording/segmenter.py, on-demand HLS transcoder app/recording/hls.py, subtitle audio puller app/subtitles/session.py, and the worker's OpenCV capture). Each source type gets the tightest -protocol_whitelist for 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 enforces client_max_body_size; this is the defensive in-app duplicate.
  • Pixels: after cv2.imdecode, _decode_image() rejects anything whose decoded height × width exceeds MAX_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; merge source_ids ≤ 1000 entries; split sighting_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 Principal carries an email for display, but the API does not gate operations by identity or role.
  • Local-dev escape hatch. AUTH_REQUIRED=false disables 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.example documents 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.