Skip to content

feat(mediaauth): playback authorization (token + IP/country/UA/referer chain) — B / S-13#21

Merged
ntt0601zcoder merged 1 commit into
mainfrom
fix/media-playback-auth
Jun 15, 2026
Merged

feat(mediaauth): playback authorization (token + IP/country/UA/referer chain) — B / S-13#21
ntt0601zcoder merged 1 commit into
mainfrom
fix/media-playback-auth

Conversation

@ntt0601zcoder

Copy link
Copy Markdown
Owner

Part B — media/playback auth plane

Open-Streamer had no playback access control: anyone reaching a stream URL could watch it, ?token= was recorded but never enforced, and a tokened SRT streamid failed to resolve (audit S-13). This adds a playback authorizer enforced on every delivery protocol.

Design — internal/mediaauth

A Flussonic-style chain (deny wins → allow-list gates → policy gate):

  1. ClientIP / Country / User-Agent on a Deny list → reject
  2. any non-empty Allow list the request misses (IP / country / UA) → reject
  3. AllowedDomains set and the Referer host isn't covered → reject
  4. effective policy == token and token missing/invalid → reject
  5. else allow

Token = HMAC-SHA256, client-signed, server verify-only:
token = "<exp>.<base64url(HMAC(secret, "code|exp"))>" — constant-time compare, expiry- and stream-code-bound. Clients mint it with the shared secret via the documented mediaauth.SignToken format; no server mint endpoint (per request — server only verifies).

Wiring (authorize before any bytes/state)

  • HTTP (HLS / DASH / MPEGTS / DVR) — dispatchMedia.mediaAllowed.
  • RTMP / SRT / RTSP play — publisher.playAllowed.

Effective policy = per-stream Stream.PlaybackAuth (public/token, template-inherited) else global auth.media.default_policy; static rules (IP/country via the existing sessions GeoIP, UA, referer) are global. Disabled by default (no behaviour change). Fail-closed when token policy has no secret. Config auth.media.*, hot-reloaded (atomic snapshot). Per-stream policy resolves from the publisher's in-memory table for live streams (O(1), no store read on the hot path) and from the store for stopped-stream DVR archives.

Adversarial review → 2 real issues found & fixed

  1. ABR renditions were authed under <code>/track_N → a token for <code> was rejected (broke transcoded/ABR token playback). Now keyed on the parent code.
  2. Stopped-stream DVR archive policy downgraded to global default. Now resolved from the store so a token stream stays protected.
    Plus the SRT ?token= parse bug (srtStreamCode strips the query).

Caveats (separate findings, documented)

  • IP/country rules trust r.RemoteAddr from RealIP/X-Forwarded-For → need a header-overwriting trusted proxy (S-15/S-17).
  • RTMP play carries no token (IP/country still apply).
  • Dynamic HTTP-callback (on_play) backend + per-stream rule overrides — planned follow-up.

Config

auth:
  media:
    enabled: true
    default_policy: token        # public | token
    token_secret: "<shared-secret>"
    deny_countries: ["CN"]
    allow_ips: ["10.0.0.0/8"]
    allowed_domains: ["example.com"]

Per-stream override: playback_auth: public|token on the stream/template.

Test

internal/mediaauth suite (deny/allow per dimension; token sign/verify/expiry/tamper/cross-stream; per-stream override; fail-closed; hot-reload), TestStripABRTrackSlug. go test -race ./internal/mediaauth/ ./internal/publisher/ ./internal/api/... green, golangci-lint 0 issues, full go build ./... green.

…referer chain)

Open-Streamer had no media-plane access control: anyone who could reach a
stream URL could watch it, the ?token= param was recorded but never enforced,
and a tokened SRT streamid failed to resolve (audit S-13). Add a playback
authorizer enforced across every delivery protocol.

internal/mediaauth: a Flussonic-style chain — deny IP/country/UA wins, then
allow-list gates (IP/country/UA + Referer allowed-domains), then a per-stream
token-policy gate. Tokens are HMAC-SHA256, client-signed and server-verified
(token = "<exp>.<base64url(HMAC(secret, code|exp))>"): constant-time compare,
expiry- and stream-code-bound. The server only verifies — clients mint tokens
with the shared secret via the documented SignToken format (no mint endpoint).

Wiring (authorize before any bytes/state):
- HTTP (HLS/DASH/MPEGTS/DVR) in dispatchMedia.mediaAllowed.
- RTMP/SRT/RTSP play sites via publisher.playAllowed.
Effective policy = per-stream Stream.PlaybackAuth (public/token, template-
inherited) else global auth.media.default_policy; static rules are global.
Country uses the existing sessions GeoIP. Disabled by default (no behaviour
change); fail-closed when token policy has no secret. Config auth.media.*,
hot-reloaded via the runtime diff (atomic snapshot swap). Per-stream policy is
resolved from the publisher's in-memory table for live streams (O(1), no store
read on the hot path) and from the store for stopped-stream DVR archives so a
token stream isn't downgraded.

Also fixes: SRT srtStreamCode strips the ?token= query so tokened streamids
resolve; ABR renditions key auth on the parent stream code so a token for
<code> covers /<code>/track_N/… (adversarial review found both — without the
strip, token playback of transcoded/ABR streams was broken).

Closes the media-plane half of S-13. Caveats (separate findings): IP/country
rules trust RealIP/X-Forwarded-For, so they need a trusted proxy (S-15/S-17);
RTMP play carries no token (IP/country still apply); the dynamic HTTP-callback
backend and per-stream rule overrides are planned follow-ups.

Tests: internal/mediaauth chain+token suite (deny/allow per dimension, token
sign/verify/expiry/tamper/cross-stream, per-stream override, fail-closed,
hot-reload), TestStripABRTrackSlug. Adversarially reviewed.
@codecov-commenter

Copy link
Copy Markdown

@ntt0601zcoder ntt0601zcoder merged commit 9f99d2a into main Jun 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants