Please do not open a public issue for a security problem.
Report it through GitHub's private vulnerability reporting: go to the Security tab and open a draft advisory. That keeps the report private until there is a fix.
What helps most in a report:
- the version (
polyemesis -version) and how it is deployed — binary, Docker, behind a reverse proxy; - whether the instance is exposed to the internet, a LAN, or localhost only;
- what an attacker needs to start with: no access at all, a network route to the ingest port, an authenticated session, a valid API token;
- a reproduction, ideally against a fresh install.
You should get an acknowledgement within a few days. If a fix is warranted it will ship with an advisory crediting you, unless you would rather not be named.
This is a young project with a single maintained line. Security fixes land on
main and in the next release. There are no long-term support branches.
Being explicit about this is more useful than a list of features, because most real incidents come from a mismatch between what an operator assumed and what the software actually promises.
-
The admin session. bcrypt password hashing, a JWT in an
HttpOnly,SameSite=Laxcookie, CSRF double-submit on every state-changing request. -
Revocation that actually revokes. Each user row carries a token epoch that every session token is signed against and every request re-checks. Changing the password bumps it in the same statement, so every session issued before the change stops working immediately — a stolen cookie does not outlive the password it was obtained under. The check fails closed: a token whose epoch cannot be established is refused rather than admitted.
-
Login brute force. Five free attempts per client address, then a delay doubling from 2s, capped at 5 minutes, with
Retry-Afteron the 429. The cap and the one-hour idle reset are deliberate: an uncapped lockout is a denial-of-service against the operator's own server. Counters are in memory only, so a restart never strands you outside your own instance. -
Secrets at rest. Platform OAuth tokens and client secrets are encrypted with NaCl secretbox. API tokens are stored as hashes. TLS private keys live in
<dataDir>/tls/, restricted to the account the server runs as —0700on the directory and0600on the keys on Linux and macOS, and an explicit DACL granting only that account andSYSTEMon Windows.The Windows half is stated separately because it is not the same mechanism. Go's
os.FileModeis a Unix concept that the Windows syscall layer discards, soos.MkdirAll(dir, 0700)succeeds there and restricts nothing. polyemesis sets a protected DACL instead (seeinternal/fsperm), which detaches the directory from the inheritable permissions it would otherwise pick up from its parent — under the default ACL on a system drive those includeBUILTIN\Users. The grant is marked inheritable so that files written into the directory later are covered too, which is what protects the ACME account key:autocertcreates that file through its own code path, so inheritance is the only mechanism that can reach it. -
Secrets in transit to you. No stream key, client secret, API token or TLS private key is ever returned by an API or written to a log. Webhook URLs — which carry their credential in the path — are masked in every response.
-
Path confinement. File destinations,
file://pull sources, slate images, recording and clip downloads are all confined to the data directory. Paths that come from the database are never trusted as filesystem paths. -
Uploads name themselves.
POST /api/v1/mediais the one endpoint where a caller supplies both the bytes and something filename-shaped, so the supplied name is treated as a hint and thrown away: the server generates the stored name with a random suffix, which also means an upload cannot overwrite an existing one by guessing it. The separator check tests both/and\on every platform rather thanos.PathSeparator, whose meaning changes with the build target — that exact bug once let a forward slash through on Windows. Uploads are behind the session, so an API token cannot write bytes to the disk, and an oversized, empty or cancelled upload leaves nothing behind. -
Inbound webhooks. Kick's chat webhook is verified against Kick's published RSA public key before its body is read, and the handler refuses the request outright when no verifier is configured. An unverifiable webhook endpoint is an unauthenticated write path, so it fails closed rather than open.
-
Browser-side hardening. Responses carry a CSP,
X-Frame-Options: DENY,nosniff,Referrer-Policy: no-referrer, and aPermissions-Policydisabling camera, microphone and geolocation.The one exception is
/watch, and only when you have turned onallowCrossOriginfor playout: a page whose purpose is to sit in an iframe on somebody else's site cannot also refuse to be framed. That page alone dropsX-Frame-Optionsand opensframe-ancestors; the admin console keeps the strict policy. LeavingallowCrossOriginoff keeps the exception closed.
These are design decisions, not oversights. Read them as operating instructions.
- There is one user. No multi-user model, no roles, no per-destination permissions. Access to the UI is full control of the server's streaming — and, through file destinations and expert mode, meaningful control of the machine.
- Expert mode is arbitrary FFmpeg arguments. It exists because operators need it, it is guarded by a confirmation showing exactly what will be spliced in, and it is still a way to make FFmpeg do things. Treat the ability to reach it as equivalent to shell access.
- RTMP ingest is only as protected as its stream key. SRT is authenticated by construction — the publish token is the address, so a publisher that cannot present a valid one is refused. RTMP has no such routing: it is gated by the stream key alone, and it serves at most one source.
- Nothing is encrypted on the wire unless you say so. Plain HTTP means the password and session cookie cross the network in clear text. The server warns about this at startup; the warning is not decorative.
- There is no audit log. Nothing records which change was made when, or from where. With one operator that is a gap rather than a hazard, but do not deploy this expecting to reconstruct events after an incident.
- The ingest port is reachable even when the UI is not. Binding the web UI to loopback does not move the ingest listener, which must stay reachable for your encoder. A valid token is the only thing standing in front of it, plus the SRT passphrase if you set one.
If you enable the model checker, chat messages leave your server for whichever endpoint you configure. That is the whole point of it, and it is worth saying plainly rather than leaving in a settings tooltip.
It is off by default. The endpoint is yours to choose, so a locally hosted
OpenAI-compatible model keeps everything on your own hardware. The API key is
sealed with the same NaCl secretbox as your platform tokens and is never
returned by any endpoint — the settings blob carries only hasApiKey.
The rules and history checkers send nothing anywhere. They are local, and they are what catches most abuse.
The endpoint is not filtered against private addresses. A private address is
the recommended configuration — Ollama or vLLM on 127.0.0.1 or a LAN host —
so blocking those would reject the deployment we suggest. Only an authenticated
admin can set the field, and an admin can already read your tokens and edit your
destinations, so it grants no privilege they did not already have. Treat it the
way you treat every other admin-only setting: if untrusted people can reach your
admin UI, this field is not your biggest problem.
- Do not expose it to the internet, or to a LAN you do not control, without
TLS.
tls.mode: autois the one-line fix. Binding to127.0.0.1and reaching it over an SSH tunnel is the zero-configuration alternative. - Put it behind a reverse proxy if you want anything resembling access control,
and set
trustProxyHeaders: trueso throttling sees real client addresses. - Set an SRT passphrase or an RTMP stream key even on a private network.
- Rotate the source token if it has ever been in a chat message, a screenshot or
a support thread.
POST /api/v1/sources/{id}/tokendoes it with a five-minute grace period so a live encoder is not cut off. - Back up
<dataDir>, and treat it as secret material — it holds the database, the encryption key and the TLS private keys.
HSTS is opt-in and is never sent over a self-signed certificate. This is deliberate: an HSTS header from a self-signed instance pins the browser to HTTPS for that host for months, and if the certificate is later lost or the instance moves, the operator is locked out of their own tool with no obvious way back. docs/TLS.md has the full reasoning.