feat: honor codec preference when selecting variants from a multicodec HLS source - #92
Conversation
…c HLS source (closes #65) Multicodec HLS masters that advertise both avc1 and hvc1 variants at the same bandwidth caused the engine to advertise one codec while serving the other, because @eyevinn/hls-vodtolive indexes variant playlists by bandwidth only. Filter the source master to a single codec family at VOD-load time inside docker-fast, before it reaches the engine, so bandwidth buckets never mix codecs. Controlled by OPTS_CODEC_PREFERENCE (avc1/hvc1), defaulting to off so single-codec sources are unaffected. Flips the #63 repro test to assert every advertised CODECS matches the served-segment codec. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
birme
left a comment
There was a problem hiding this comment.
pr-reviewer verdict: REQUEST CHANGES
The functional side is solid — npm ci/lint/pretty clean, npm test green (8/8), the #63 multicodec repro is genuine (not tautological), the feature defaults OFF, and single-codec masters are provably untouched. But there is a critical security hole that blocks merge:
- critical —
src/server.ts/codecfilter/master.m3u8?src=<url>: the handler doesawait fetch(src)on a fully attacker-controlledsrcwith no validation (no scheme allowlist, no block on loopback/private/link-local/metadata ranges like169.254.169.254,127.0.0.1,10/8,192.168/16), and echoes the upstream body back verbatim. This is a read-SSRF / open proxy. The route is registered unconditionally (live even whenOPTS_CODEC_PREFERENCEis unset), and the UI server binds0.0.0.0. Pre-PR this port only served static UI files — this PR introduces the outbound-fetch surface. - minor —
src/server.ts500 handler: returns the raw error message to the client, acting as an SSRF oracle. Return a generic error; log detail server-side only.
Required before approval:
- Validate
srcbefore fetching: http(s)-only scheme; reject private/loopback/link-local/metadata address ranges (re-check post-DNS-resolution to avoid rebinding); and/or restrict to an operator-configured origin allowlist. - Stop echoing raw error messages to the client.
- Recommended (not strictly blocking): only register
/codecfilterwhenOPTS_CODEC_PREFERENCEis set, and/or bind the UI server to loopback unless explicitly exposed.
Everything else (tests, lint, defaults-off, non-tautological repro, commit message, no trademarks) passes — re-review should be quick once src validation lands.
🤖 Automated via Channel Engine Dev daily-backlog-pr skill
) Guard the self-hosted /codecfilter master endpoint against read-SSRF / open-proxy abuse: - Add validateSourceUrl (src/plugins/utils.ts): accepts only http/https, rejects hosts in private/loopback/link-local/unique-local/metadata ranges (incl. IPv4-mapped IPv6), and resolves the hostname (dns.lookup all) to re-check every address. Returns a node-fetch agent that pins the connection to the validated IP, defeating DNS rebinding. - Wire the validator into src/server.ts before fetching; only register the route when a codec preference is configured, and return a generic error string to the client (log detail server-side only) so it cannot act as an SSRF oracle. - Add jest tests for the validator (mocked dns). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
birme
left a comment
There was a problem hiding this comment.
pr-reviewer verdict: APPROVE
Independent re-review of the SSRF fix (fa2dd99) on top of the codec-preference feature (06e5008). The prior REQUEST CHANGES critical (read-SSRF / open-proxy on /codecfilter/master.m3u8?src=) is resolved.
Deterministic proof (all on branch issue-65/codec-preference, HEAD fa2dd99)
- PROOF:
npm run lint→eslint .→ LINT_EXIT=0 (no errors) - PROOF:
npm run pretty→All matched files use Prettier code style!→ PRETTY_EXIT=0 - PROOF:
npm run build(tsc --project ./) → BUILD_EXIT=0 - PROOF:
npm test→Test Suites: 3 passed, 3 total; Tests: 28 passed, 28 total(incl. validate_source_url.test.ts and the multicodec CODECS-match assertions)
SSRF verification (I probed the compiled validator with a mocked resolver — no bypass found)
- PROOF:
node probe→ literal127.0.0.1,0.0.0.0,10.x,172.16-31.x,192.168.x,169.254.169.254,[::1]all → REJECT.172.15.0.1(public) correctly ACCEPT — range boundaries are exact. - PROOF: alternate IP encodings decimal
2130706433, octal0177.0.0.1, hex0x7f000001/0x7f.0.0.1, short127.1, metadata decimal2852039166→ all REJECT (Node's URL parser normalizes to dotted-quad, then net.isIP + range check catches them). - PROOF: IPv4-mapped IPv6 both forms
[::ffff:127.0.0.1]and[::ffff:7f00:1]→ REJECT (normalizeAddress unwraps both). IPv6 zone-id[fe80::1%eth0]→ REJECT (URL parse fails). Trailing-dot hostlocalhost.resolving to metadata → REJECT. userinfo trickexpected@127.0.0.1→ REJECT (host parsed as 127.0.0.1).127.0.0.1@evil.com→ host correctly parsed asevil.com(that IS the real host; validated/pinned normally). - Redirect / DNS-rebinding analysis (node-fetch v2, default
redirect:'follow'): the connection-pinningagentis passed as a FUNCTION and node-fetch re-invokesagent(parsedURL)on every redirect hop (lib/index.js:1363-1366, reused viaagent: request.agentat :1557). The customlookupis hostname-agnostic and always returns the ORIGINAL validated IP, so a 3xx redirect to an internal hostname (e.g.169.254.169.254) is NOT independently resolved — the socket still targets the pinned public IP. The "redirect rebinds to a freely-resolved new host" concern does not apply here. Fail-closed throughout (unresolvable host, any-private-among-all, unknown IP kind → reject). - Route now gated behind
getCodecPreference()being set (server.ts), shrinking attack surface to when the feature is enabled; on rejection a genericSource URL not permittedis returned and detail is logged server-side only — no SSRF oracle. Otherfetch/resolveRedirectcall sites (loop/webhook/playlist/barker) consume operator-configured URLs, not per-request attacker input, so they are out of scope for #65.
Checklist
- Trademarks: none in the diff (grep for known commercial/DRM names → 0 hits).
- Conventions: matches repo eslint/prettier/jest/tsc; node-fetch v2 agent usage is correct.
- Commit messages:
feat: honor codec preference … (closes #65)andfix: validate and pin codecfilter source URL to prevent SSRF (closes #65)— both conform.
Minor (non-blocking) suggestions for a follow-up, not required for merge:
suggestionsrc/plugins/utils.ts:578 — pinning toresolved[0]only; if that single address later becomes unreachable there's no failover, but all addresses were validated so it is safe. Consider pinning to the full validated set.suggestionserver.ts — considerredirect:'manual'+ explicit re-validation per hop for defense-in-depth, though the hostname-agnostic pinned lookup already blocks the rebinding path.
No critical or major findings remain. Approving.
Summary
Changes
OPTS_CODEC_PREFERENCE(avc1/hvc1) to keep only the preferred video codec family from a multicodec source master, so bandwidth buckets never mix codecs before reaching@eyevinn/hls-vodtolive./codecfilter/master.m3u8endpoint; variant URIs are absolutized so segment resolution still targets the upstream origin.OPTS_CODEC_PREFERENCEandOPTS_MASTER_FILTER_BASE_URLindocs/plugins.md.Design note: the upstream engine fetches
VodResponse.uriitself and exposes no per-VOD codec-filter input (confirming the #64 trace finding), so the only in-docker-fast interception point is rewriting the VOD URI to a filtered master served locally — hence the small proxy endpoint rather than an engine option.Test plan
npm run lint→ eslint . → no errorsnpm run pretty→ All matched files use Prettier code style!tsc --project ./→ BUILD_EXIT=0npm test→ Test Suites: 2 passed; Tests: 8 passed — including "FIXED: every advertised CODECS matches the served-segment codec" and "leaves a single-codec master unchanged (no preference set)"Closes #65
🤖 Automated via Channel Engine Dev daily-backlog-pr skill