Security¶
topicast is built for a private network. It can be exposed publicly behind TLS, but the defaults assume you control who can reach the port.
Keys¶
A key looks like tc_1a2b3c4d_<48 hex chars>:
tc_makes it recognisable to secret scanners.- The middle part is the public id, stored in clear and used for lookup.
- Only
HMAC-SHA256(TOPICAST_SECRET_KEY, key)is stored. The key itself is shown once. - Comparison is constant-time. A failed attempt logs the path and client, never the key.
TOPICAST_SECRET_KEY is the pepper. Keep it out of the image and out of version control;
changing it invalidates every key at once — which is also your break-glass move.
Scopes and aliases¶
Give each service its own key with the least it needs:
topicast keys create grafana --scope hooks --alias alerts
topicast keys create deploy-ci --scope send --alias deploys
topicast keys create ops-tool --scope send --scope edit --alias '*'
| Scope | Allows |
|---|---|
send |
POST /v1/messages, GET /v1/messages/{id} |
edit |
PATCH and DELETE on messages |
hooks |
POST /v1/hooks/... only |
A key restricted to alerts gets 403 alias_not_allowed for any other alias, and a hooks
key cannot post to /v1/messages at all — which is what makes it safe to put in a URL.
Revoke immediately when a service is retired or a key leaks:
Webhook tokens in URLs¶
Most webhook senders cannot set headers, so the key travels as ?token=. That means it can
appear in proxy logs and browser history. Mitigations, in order of importance:
- Only ever use
--scope hookskeys there, scoped to one alias. - topicast never logs query strings.
- Verify signatures where the sender supports them — set
hooks.github.secret. - Rotate the key if the URL was shared.
Exposure¶
- Published Docker ports bypass UFW and firewalld.
ports: "8080:8080"is reachable from the internet even when your firewall denies it. Bind to127.0.0.1or a VPN address. - Disable the public surfaces if the port is shared:
TOPICAST_DOCS_ENABLED=false,TOPICAST_METRICS_ENABLED=false. - Terminate TLS in a reverse proxy; topicast speaks plain HTTP.
- Set
TOPICAST_TRUSTED_PROXIESto your proxy so forwarded client IPs are trustworthy.
Browsers¶
CORS is off unless TOPICAST_CORS_ORIGINS lists origins explicitly; the wildcard is refused at
startup. Only GET and POST are exposed to browsers, and credentials are never allowed.
Remember what a browser call implies: the API key ships to the visitor. Treat any key used in a
web page as public, scope it to one alias with --scope send, and prefer keeping the key in
your own backend.
Input handling¶
- Markup: text is passed to Telegram as sent. If you interpolate user input into
HTMLorMarkdownV2, escape it on your side — topicast escapes only the level prefix it adds. - Templates: generic webhook templates run in a Jinja2 sandbox
(
ImmutableSandboxedEnvironment), which blocks attribute traversal and mutation, but a template still decides what is sent. Only add templates you trust. - Uploads: files are streamed to
/data/spoolwith generated names — the client-supplied filename is only forwarded to Telegram, never used as a path. Size is capped byTOPICAST_MAX_UPLOAD_MB. - Media by URL: Telegram fetches the URL, not topicast, so a URL cannot be used to probe your internal network through this service.
- Bodies: webhook payloads are capped at 1 MB.
What topicast does not do¶
- No user accounts, sessions or cookies — keys only.
- No inbound Telegram updates: it never polls
getUpdatesand never exposes a Telegram webhook. - No message content in metrics; logs hold a message id and alias, not the text.
Reporting a vulnerability¶
Privately, through GitHub Security Advisories. See SECURITY.md.