File size: 7,498 Bytes
63dd946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# Security review of the serving code

**When and how.** 8 October 2026, a read-only review of the gateway: trace every untrusted input to every sink,
confirm each candidate end to end, keep only findings with confidence 8 of 10 or more.

**Scope.** `crates/diar-gateway`, `crates/diar-triton`, `crates/diar-audio` (decoding untrusted uploads), the
engine's input path, `python/anuvani_diar/client.py`, the Triton models, image and scripts, and the compose stack.
Threat model: clients are untrusted; the gateway binds 127.0.0.1 by default but may be exposed; Triton listens on
127.0.0.1 only; the host may have other local accounts.

## Findings and fixes

| # | Finding | Severity | Fix | Test |
|---|---|---|---|---|
| 1 | The Python client sent its bearer token along any HTTP redirect (urllib copies every request header to the redirect target), so a forged 302 on an exposed, non-TLS deployment would hand the token to another host | medium | the client never follows redirects; a 3xx is an error (the gateway never redirects) | `test_client_does_not_follow_redirects` |
| 2 | The size limit and the memory budget were checked on the decoded audio only; resampling to 16 kHz came after, unchecked, so a file declaring a low sample rate (libsndfile accepts any) could grow up to 16,000 times past both, e.g. 2.4 MB at 1 Hz becoming 38.4 GB | medium | the resampled size, `ceil(frames x 16000 / rate)` samples, is computed from the header with the decoded one; both must be within `--max-audio-gb`, and the budget holds both | `test_a_low_sample_rate_cannot_resample_past_the_limit` (engine and gateway) |
| 3 | Grafana allowed anonymous viewing; on a host with other accounts the loopback is theirs too | low | anonymous access off; the admin password is generated into `artifacts/compose/grafana_password` (mode 0600) | configuration |

Checked and found sound (from the review): the token middleware covers every route but `/health`, with a
constant-time comparison; upload names, uris and spool files (no path separators, exclusive creation with mode
0600 in a directory refused unless it is ours and 0700); path jobs (canonical path under the allowed root, regular
files only, one generic refusal message); no injection into RTTM lines or JSON through `uri`; Triton responses
parsed with checked slicing only; the `unsafe` blocks in decoding and tensor encoding; panics contained per file
(engine) or per request (axum); no token or server path in logs or error messages; every published port on
127.0.0.1, read-only mounts, no privileged containers.

## What remains (by design or out of scope)

- **libsndfile parses untrusted uploads inside the gateway process**, as soundfile does inside the release. A
  parser bug in libsndfile 1.2.2 would take the gateway down. 2,750 damaged files decode exactly as soundfile does
  (`tests/test_audio_decode.py`), which is a check of equivalence, not of libsndfile's own safety. Decoding in a
  separate process would contain such a bug; not done.
- **Without a token, any local account can use the gateway**, and with `--allow-path-root` have it read files under
  that root with your account's rights. `triton/README.md` says to set a token on a shared machine.
- **Path jobs**: the file is opened by its canonical path after the check, so someone who can write inside the
  allowed root could swap a path component in between. That needs write access inside a root the operator chose.
- **Jobs live in the gateway's memory**: a gateway restart loses them; the Python client resubmits (tested), other
  clients must.

## Second review: the FastAPI service (9 October 2026)

A read-only review of `triton_inference/service` (the FastAPI app, its settings, spool, Dockerfile and build script)
and its Rust binding (`crates/diar-py/src/service.rs`), against the pinned libraries (fastapi 0.143.0, starlette
1.7.0, uvicorn 0.54.0, h11 0.16.0, python-multipart 0.0.32). Two findings were confirmed by offline measurement
before they were fixed.

| # | Finding | Severity | Fix | Test |
|---|---|---|---|---|
| 4 | The resampling filter was not counted. torchaudio's sinc filter for a rate `sr` is a (16000 / g, 2 x width + sr / g) table of float64, g = gcd(sr, 16000), whatever the file's length; a 500-byte WAV declaring 16,001 Hz asks for 2 GB per table (measured: +4.2 GB peak, 4.4 s), one declaring 1,000,003 Hz for about 128 GB. Item 2's limits saw only 400 bytes decoded and 8 bytes resampled. This reached the gateway and the pip package too (same engine) | high | the engine computes the filter's size from the header's rate (`resample_filter_bytes`, checked against torchaudio 2.1.1's own tables) and refuses a file whose filter exceeds 64 MiB before anything is resampled; every common rate (8 to 192 kHz) needs under 3 MB. A file the release would have resampled at such a rate is now refused: the one place where the engine's behaviour departs from the release's, for headers no recorder writes | `test_a_rate_that_does_not_reduce_cannot_build_a_huge_resampling_filter` (engine and service); `resample_filter_sizes` (Rust) |
| 5 | Multipart bodies were bounded only in their file part: parts with no data but distinct names of up to 4 KiB each were kept in a dict without limit, and with `Transfer-Encoding: chunked` (no `Content-Length`) nothing capped the non-file bytes (measured: memory grew 1:1 with the bytes sent) | high | at most 16 parts; boundaries, part headers, fields and anything after the closing boundary together within 128 KiB above `--max-upload-mb`, checked while streaming | `test_multipart_framing_is_bounded` |
| 6 | The image's default command listened on 0.0.0.0 and a token was optional, so `docker run --network host` without a token exposed the API on every interface | medium | `--listen` on an address other than the loopback refuses to start without a token, unless `--insecure-no-token` | `test_listening_beyond_the_loopback_needs_a_token` |
| 7 | An unusual spool `OSError` without `strerror` put the spool file's path into the error message | low | a generic message instead | review |

Not changed, recorded:

- **One token, one trust domain.** Any holder of the token can read or cancel any job and replay any
  `Idempotency-Key`. Job ids are 128 random bits, and the result cache answers only for audio the caller sent, so
  nothing leaks between callers who know nothing of each other's jobs; but the service is single-tenant. Several
  tenants need a reverse proxy with per-tenant tokens in front, or per-token scoping (not done).
- **Path jobs** keep the gateway's check-then-open gap (above).
- **Base images** are pinned by tag (`ubuntu:22.04`, `tritonserver:24.10-py3`, `tritonserver:25.06-py3`), not by digest; `/docs` and `/redoc`
  load their JavaScript from cdn.jsdelivr.net (FastAPI's defaults), behind the token.

## Tritons on other machines (10 October 2026)

`diar up --tritons-only --bind ADDR` publishes a machine's Tritons on ADDR so that another machine's service can use
its GPUs (`diar up --remote`). Triton has no authentication: anyone who reaches those ports can run the networks and
read the server's metadata. The Tritons start with `--model-control-mode=none`, so models can be neither loaded nor
unloaded through the API, but the ports must stay on a private network: bind to a private address (the command
refuses to publish without `--bind`), never to a public interface, and firewall the ports to the service's machine.
The service's own token and limits are unchanged.