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.
-
Scoped API tokens. A token is created
readoradmin, and omitting the choice givesread. Areadtoken reachesGETandHEADplus two POSTs that compute an answer and write nothing; anything else is refused with a 403. The rule is by HTTP method rather than by a list of routes, so a route added later is refused by construction instead of by somebody remembering to classify it, and the small allowlist is additive — forgetting to extend it denies, never permits.No token of either scope can manage tokens, change the password, upload or delete media, or complete an OAuth connect flow. Those are session-only.
A rule about the HTTP verb cannot see what a response contains. That is a real limit rather than a theoretical one, and it is the second half of this feature. A
readtoken is additionally refused every stored CREDENTIAL, in the response body rather than by hiding the route: the source publish tokens and publish URLs, the SRT passphrase and legacy RTMP stream key on both the primary and the standby ingest, pull URLs carryinguser:pass@, the MQTT broker URL, destination stream keys and an Icecast mount's password, the automod model endpoint when it carries an?api_key=, a destination's expert FFmpeg arguments, the constructed ingest URL on/system, and the playout playback token together with the three URLs that embed it. Each of those is a publish or watch credential, so a credential that could read one could use it — and "read-only" has to mean it cannot put video into your programme or watch a private one.The listings still work: values are blanked or masked, and the JSON path of every field is the same for a
readtoken as for an admin. Where a field carriesomitempty, redaction puts[redacted]in it rather than emptying it, because an empty string would delete the key and change the shape of the document. Akind: filedestination'surlis a filename rather than a URL and comes back intact — masking it would destroy a field that never held a credential. A session oradmintoken sees exactly what it saw before.Live playout media is content, and a
readtoken does not get it. The three playout routes sit outside every authenticated group by necessity — a viewer has no account — so the check runs per request in the handler, and a bearer token buys nothing there: on a stream you have not published, areadtoken receives byte for byte what a stranger receives.Public: falseis a decision about the resource, and a role-level scope must not silently override it.Recorded content is content too. A
readtoken lists recordings, clips, stems and sessions, sees their durations, sizes and status, and sees whether a recording has a transcript. It does not get the media bytes and it does not get the words: the download routes and both transcript routes are refused, and so isGET /library/search. Search is on that list becausedb.TranscriptHitcarries the segmentText, its neighbouringContextand theSpeaker— so a token that iterated common words would reconstruct whole transcripts without ever naming a route withtranscriptin its path. The line is drawn at the bytes, not at the URL.GET /librarystill returns the bare list of speaker labels. That is who appears in the archive rather than what was said, and a dashboard that groups by speaker needs it.Five GETs are not reads, and a
readtoken is refused them outright. The two expert-command endpoints return the FFmpeg argv with the stream key in it, and masking that would break the operator's guarantee that the command shown is the command that runs./clipper/.../keyframesspawnsffprobe;/platforms/accounts/{id}/statsand/metadata/broadcast-windowcall the platform and can refresh and persist an OAuth token.?redetect=on/encodersneedsadminfor the same reason. The dashboard's/hls/*preview is session-only: fetching a playlist starts an encoder and polling keeps it running.Client secrets and the MQTT password were already never serialised outward, and platform OAuth secrets carry
json:"-".Tokens that predate scopes are
admin, because they already could do everything and narrowing them on upgrade would break a running script without telling anyone. Revoke and re-mint to narrow one. -
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. Uploading and deleting media are session-only, so an API token cannot write bytes to the disk; a token can stillGET /api/v1/mediato list what is stored. An oversized, empty or cancelled upload leaves nothing behind.This paragraph claimed the session requirement before anything enforced it. The routes sat in the ordinary authenticated group, and the CSRF middleware passes token-authenticated requests through on purpose — nothing attaches an
Authorizationheader on its own, so there is no cross-site forgery left to stop — which meant a token-onlyPOSTstored a file for as long as the claim went unchallenged. The routes now sit in a session-only router group, so the route table states the rule instead of the prose doing it alone. -
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. Both protocols are now
authenticated by construction — the publish token is the address, over SRT as
streamidand over RTMP as the stream key in the URL path, so a publisher that cannot present a valid one is refused. The difference that remains is the wire: SRT can additionally encrypt with a passphrase, RTMP cannot unless you put it behind RTMPS. Subscribing to the RTMP listener is restricted to loopback, so a publish credential never doubles as a viewing one. - 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.