Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2353,10 +2353,6 @@ const docTemplate = `{
"config.IngestorConfig": {
"type": "object",
"properties": {
"allow_private_targets": {
"description": "AllowPrivateTargets permits HTTP/HLS ingest URLs (and their redirects /\nplaylist-chosen targets) to reach RFC1918 / IPv6-ULA / RFC6598 addresses.\nDefault false → private targets are blocked at dial time (S-4). Loopback,\nlink-local, and cloud-metadata (169.254.0.0/16) are ALWAYS blocked\nregardless of this flag. Enable only on a trusted network with on-prem\nsources (LAN cameras, internal CDNs).",
"type": "boolean"
},
"hls_max_segment_buffer": {
"description": "HLSMaxSegmentBuffer caps the number of pre-fetched HLS segments held in memory.\nThis is a server-wide memory guard, not a per-stream policy.",
"type": "integer"
Expand Down
4 changes: 0 additions & 4 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2346,10 +2346,6 @@
"config.IngestorConfig": {
"type": "object",
"properties": {
"allow_private_targets": {
"description": "AllowPrivateTargets permits HTTP/HLS ingest URLs (and their redirects /\nplaylist-chosen targets) to reach RFC1918 / IPv6-ULA / RFC6598 addresses.\nDefault false → private targets are blocked at dial time (S-4). Loopback,\nlink-local, and cloud-metadata (169.254.0.0/16) are ALWAYS blocked\nregardless of this flag. Enable only on a trusted network with on-prem\nsources (LAN cameras, internal CDNs).",
"type": "boolean"
},
"hls_max_segment_buffer": {
"description": "HLSMaxSegmentBuffer caps the number of pre-fetched HLS segments held in memory.\nThis is a server-wide memory guard, not a per-stream policy.",
"type": "integer"
Expand Down
9 changes: 0 additions & 9 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -423,15 +423,6 @@ definitions:
type: object
config.IngestorConfig:
properties:
allow_private_targets:
description: |-
AllowPrivateTargets permits HTTP/HLS ingest URLs (and their redirects /
playlist-chosen targets) to reach RFC1918 / IPv6-ULA / RFC6598 addresses.
Default false → private targets are blocked at dial time (S-4). Loopback,
link-local, and cloud-metadata (169.254.0.0/16) are ALWAYS blocked
regardless of this flag. Enable only on a trusted network with on-prem
sources (LAN cameras, internal CDNs).
type: boolean
hls_max_segment_buffer:
description: |-
HLSMaxSegmentBuffer caps the number of pre-fetched HLS segments held in memory.
Expand Down
8 changes: 0 additions & 8 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,6 @@ type IngestorConfig struct {
// HLSMaxSegmentBuffer caps the number of pre-fetched HLS segments held in memory.
// This is a server-wide memory guard, not a per-stream policy.
HLSMaxSegmentBuffer int `mapstructure:"hls_max_segment_buffer" json:"hls_max_segment_buffer" yaml:"hls_max_segment_buffer"` // default 8

// AllowPrivateTargets permits HTTP/HLS ingest URLs (and their redirects /
// playlist-chosen targets) to reach RFC1918 / IPv6-ULA / RFC6598 addresses.
// Default false → private targets are blocked at dial time (S-4). Loopback,
// link-local, and cloud-metadata (169.254.0.0/16) are ALWAYS blocked
// regardless of this flag. Enable only on a trusted network with on-prem
// sources (LAN cameras, internal CDNs).
AllowPrivateTargets bool `mapstructure:"allow_private_targets" json:"allow_private_targets,omitempty" yaml:"allow_private_targets,omitempty"`
}

// BufferConfig controls the in-memory ring buffer.
Expand Down
4 changes: 3 additions & 1 deletion docs/audit/security-quality-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
---

#### S-4 (HIGH) — SSRF via unvalidated ingest/pull input URLs (content- and redirect-chosen targets)
> ✅ **FIXED** in `fix/security-critical-high` — new `internal/netguard` installs a dial-time guard (`net.Dialer.Control` on the RESOLVED IP) on the HTTP-TS and HLS pull clients (playlist + segment), so loopback / link-local / cloud-metadata are rejected at connect time and private/RFC1918 unless `ingestor.allow_private_targets` (default off). Because the guard runs on every dial it covers DNS-rebind, redirect chains, AND the attacker-chosen targets inside a remote HLS playlist. `CheckRedirect` caps the chain (10) and strips `Authorization`/`Cookie` on cross-host redirect (no credential-header leak). `decodeStreamBody` adds a save-time speed-bump (`netguard.ValidateInputURL`) for the always-blocked set. Tests: `internal/netguard` suite. **Residual (noted):** RTSP/RTMP/SRT/UDP dials aren't yet IP-guarded at dial time (lower-value — no body channel; the HTTP body-exfil + cloud-metadata vectors are covered). **Hot-reload added** (`fix/security-critical-high`): `ingestor.allow_private_targets` is now read live — `ingestor.Service` holds its config in an `atomic.Pointer` (`SetConfig`/`currentCfg`), the pull readers are built from `s.currentCfg()` on each stream start / failover switch, and `runtime.Manager.diff` calls `Ingestor.SetConfig` on an ingestor-config change, so toggling the flag applies to the next reader without a restart.
> 🚫 **WON'T FIX / RISK ACCEPTED (operator decision)** in `fix/remove-ingest-ssrf-guard` — the ingest SSRF guard was **removed**. Rationale: this server's primary role is pulling from **internal / LAN sources** (on-prem origins, internal CDNs, LAN cameras, multicast), so blocking private/RFC1918 targets is counterproductive and operationally fragile — the default-off guard caused a real production failover freeze (an internal HLS input was blocked at dial, the worker reconnect-looped, no error surfaced). The guard also only ever covered the HTTP-family pulls (HLS / HTTP-TS); RTSP/RTMP/SRT/UDP were never guarded, so it never provided complete coverage. The decisive compensating control is **S-1 (control-plane Basic auth)**: the admin API that made this SSRF remotely reachable is now authenticated, removing the unauthenticated-remote exploitation angle that made it High. Removed: `netguard.ApplyToTransport` + `CheckRedirect` on the HLS/HTTP-TS clients, the save-time `netguard.ValidateInputURL` speed-bump in `decodeStreamBody`, the `ingestor.allow_private_targets` config flag, and the `netguard:` fail-fast clause in the ingest worker. **Kept:** the `internal/netguard` package itself and its use by the **webhook HTTP client** (S-2 — a hook must never reach the local admin API / IMDS); that loopback+metadata guard stays. `IngestPolicy` / `ValidateInputURL` remain as unused helpers.
>
> 🔕 **Follow-up bug IGNORED (won't fix)** — a later investigation found the ingest guard (a) only covered HLS/HTTP-TS, leaving RTSP/RTMP/SRT internal inputs unguarded, and (b) wasn't retroactive to already-running readers when `allow_private_targets` was toggled. Both are moot now that the guard is removed; marked ignore per operator decision.

**Files:** `internal/api/handler/stream.go:660-679` (`decodeStreamBody` validates code/priority/uniqueness/watermark only — never URL host); `internal/manager/service.go:416/901/1021/1187/1227` (forwards raw `Input.URL` to ingestor on register/failover/probe/switch); pull readers `internal/ingestor/pull/httpts.go:76-85`, `hls.go:417-439/465-484`; `resolveHLSURL hls.go:659-671`; clients at `hls.go:213-214` are bare `&http.Client{}` with **no `CheckRedirect`**.
**Merges:** three reported SSRF findings (HLS playlist-content SSRF, manager-forwarded SSRF, domain-input SSRF).
Expand Down
13 changes: 0 additions & 13 deletions internal/api/handler/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/ntt0601zcoder/open-streamer/internal/domain"
"github.com/ntt0601zcoder/open-streamer/internal/events"
"github.com/ntt0601zcoder/open-streamer/internal/manager"
"github.com/ntt0601zcoder/open-streamer/internal/netguard"
"github.com/ntt0601zcoder/open-streamer/internal/publisher"
"github.com/ntt0601zcoder/open-streamer/internal/store"
"github.com/ntt0601zcoder/open-streamer/internal/transcoder"
Expand Down Expand Up @@ -692,18 +691,6 @@ func decodeStreamBody(
if err := base.Watermark.Validate(); err != nil {
return nil, &putValidationError{code: "INVALID_WATERMARK", message: err.Error()}
}
// SSRF speed-bump (S-4): reject inputs whose host is an always-blocked
// literal (loopback / link-local / cloud-metadata). The authoritative gate
// is the dial-time guard on the ingest clients (which also covers hostnames,
// redirects, and playlist-chosen targets); this is fast operator feedback.
for i, in := range base.Inputs {
if err := netguard.ValidateInputURL(in.URL); err != nil {
return nil, &putValidationError{
code: "INVALID_INPUT_URL",
message: fmt.Sprintf("inputs[%d]: %s", i, err.Error()),
}
}
}
// Transcoder.Mode validation removed alongside the Mode field in the
// native-transcoder migration — there is only one topology now.
return &base, nil
Expand Down
16 changes: 3 additions & 13 deletions internal/ingestor/pull/hls.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import (

"github.com/ntt0601zcoder/open-streamer/config"
"github.com/ntt0601zcoder/open-streamer/internal/domain"
"github.com/ntt0601zcoder/open-streamer/internal/netguard"
"github.com/ntt0601zcoder/open-streamer/pkg/version"
)

Expand Down Expand Up @@ -162,7 +161,6 @@ type hlsVariant struct {
// deferred close does not affect the new one.
type HLSReader struct {
input domain.Input
cfg config.IngestorConfig
maxBuf int

// plClient: short-timeout client for playlist GETs.
Expand All @@ -183,7 +181,6 @@ func NewHLSReader(input domain.Input, cfg config.IngestorConfig) *HLSReader {
}
return &HLSReader{
input: input,
cfg: cfg,
maxBuf: maxBuf,
}
}
Expand All @@ -204,22 +201,15 @@ func (r *HLSReader) Open(ctx context.Context) error {
// Build a shared transport so playlist + segment clients pick up the
// same TLS policy. Default uses Go's secure defaults; the operator can
// opt out via input.net.insecure_tls = true (e.g. self-signed source on
// a trusted private network — Vietnamese TV CDN often expired certs).
// a trusted private network — broadcast CDNs often serve expired certs).
transport := http.DefaultTransport.(*http.Transport).Clone()
if r.input.Net.InsecureTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // operator-opted-in for sources on trusted networks
slog.Warn("hls reader: TLS verification disabled per input.net.insecure_tls",
"url", r.input.URL)
}
// SSRF dial guard (S-4): reject loopback / link-local / cloud-metadata
// always, and RFC1918/ULA unless allow_private_targets. This covers BOTH
// playlist + segment fetches and — because the guard runs on every dial —
// the attacker-chosen targets inside a remote playlist and any redirect.
// CheckRedirect caps the chain and strips credential headers on host change.
netguard.ApplyToTransport(transport, netguard.IngestPolicy(r.cfg.AllowPrivateTargets))
redirect := netguard.CheckRedirect()
r.plClient = &http.Client{Timeout: plTimeout, Transport: transport, CheckRedirect: redirect}
r.segClient = &http.Client{Timeout: segTimeout, Transport: transport, CheckRedirect: redirect}
r.plClient = &http.Client{Timeout: plTimeout, Transport: transport}
r.segClient = &http.Client{Timeout: segTimeout, Transport: transport}

// Fresh channel and once per Open() call so the previous poll goroutine's
// deferred close(out) cannot race with this new goroutine's close.
Expand Down
14 changes: 4 additions & 10 deletions internal/ingestor/pull/httpts.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ import (
"net/http"
"time"

"github.com/ntt0601zcoder/open-streamer/config"
"github.com/ntt0601zcoder/open-streamer/internal/domain"
"github.com/ntt0601zcoder/open-streamer/internal/netguard"
"github.com/ntt0601zcoder/open-streamer/pkg/version"
)

Expand All @@ -52,10 +50,8 @@ type HTTPTSReader struct {
}

// NewHTTPTSReader constructs a reader for the given input. Connection is
// deferred to Open so the constructor never blocks. The transport carries the
// SSRF dial guard (S-4): loopback / link-local / cloud-metadata are always
// blocked; RFC1918/ULA are blocked unless cfg.AllowPrivateTargets is set.
func NewHTTPTSReader(input domain.Input, cfg config.IngestorConfig) *HTTPTSReader {
// deferred to Open so the constructor never blocks.
func NewHTTPTSReader(input domain.Input) *HTTPTSReader {
transport := &http.Transport{
// Keep-alives off: every Open is a fresh stream so pooled
// connections don't help and may surface stale TCP state.
Expand All @@ -65,14 +61,12 @@ func NewHTTPTSReader(input domain.Input, cfg config.IngestorConfig) *HTTPTSReade
DisableCompression: true,
ResponseHeaderTimeout: timeoutOr(input.Net.TimeoutSec, httpTSDefaultDialTimeout),
}
netguard.ApplyToTransport(transport, netguard.IngestPolicy(cfg.AllowPrivateTargets))
return &HTTPTSReader{
input: input,
client: &http.Client{
// Per-request dial / TLS / headers timeout. Body is unbounded.
Timeout: 0,
Transport: transport,
CheckRedirect: netguard.CheckRedirect(),
Timeout: 0,
Transport: transport,
},
}
}
Expand Down
9 changes: 4 additions & 5 deletions internal/ingestor/pull/httpts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ntt0601zcoder/open-streamer/config"
"github.com/ntt0601zcoder/open-streamer/internal/domain"
)

// newTestHTTPTSReader builds a reader that allows private/loopback targets so
// the httptest servers (127.0.0.1) used below are reachable through the SSRF
// dial guard. Production callers pass the configured value (default: blocked).
// newTestHTTPTSReader builds a reader for the httptest servers (127.0.0.1)
// used below. Ingest no longer carries an SSRF dial guard, so any target is
// reachable.
func newTestHTTPTSReader(in domain.Input) *HTTPTSReader {
return NewHTTPTSReader(in, config.IngestorConfig{AllowPrivateTargets: true})
return NewHTTPTSReader(in)
}

// HTTPTSReader streams whatever the server sends, in order, with no
Expand Down
2 changes: 1 addition & 1 deletion internal/ingestor/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func NewPacketReader(
// Raw MPEG-TS over chunked HTTP — the low-latency relay path.
// Counterpart to publisher/serve_mpegts.go on the source side.
// Same passthrough handling as UDP / HLS so PCR / PIDs survive intact.
return pull.NewTSPassthroughPacketReader(pull.NewHTTPTSReader(input, cfg)), nil
return pull.NewTSPassthroughPacketReader(pull.NewHTTPTSReader(input)), nil
case protocol.KindFile:
if vods == nil {
return nil, fmt.Errorf("ingestor: cannot resolve %q — no VOD resolver configured", input.URL)
Expand Down
31 changes: 6 additions & 25 deletions internal/ingestor/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,9 @@ type pullWorkerEntry struct {
// - Push inputs: stream key is registered in the push server routing table;
// the next encoder connection for that key is accepted and routed here.
type Service struct {
// cfgPtr holds the latest IngestorConfig, stored atomically so SetConfig can
// hot-swap it (e.g. allow_private_targets) and the next reader built on a
// stream start / failover switch observes the new value without a reboot.
cfgPtr atomic.Pointer[config.IngestorConfig]
// cfg holds the server-level ingestor config (e.g. HLSMaxSegmentBuffer),
// captured at construction and read when a reader is built.
cfg config.IngestorConfig
// listenersPtr holds the latest ListenersConfig. Stored atomically so
// SetListeners can hot-swap the value while Run reads it once at
// startup. Runtime.diff calls SetListeners THEN restarts the service
Expand Down Expand Up @@ -102,36 +101,18 @@ func New(i do.Injector) (*Service, error) {
vods := do.MustInvoke[*vod.Registry](i)

svc := &Service{
cfg: cfg,
buf: buf,
bus: bus,
m: m,
vods: vods,
registry: NewRegistry(),
workers: make(map[domain.StreamCode]*pullWorkerEntry),
}
svc.cfgPtr.Store(&cfg)
svc.listenersPtr.Store(&listeners)
return svc, nil
}

// SetConfig hot-swaps the IngestorConfig. The next reader built on a stream
// start or failover switch reads the new value (e.g. allow_private_targets) via
// currentCfg — no reboot needed. Already-running readers keep their value until
// they reconnect/switch.
func (s *Service) SetConfig(cfg config.IngestorConfig) {
cp := cfg
s.cfgPtr.Store(&cp)
}

// currentCfg returns the latest IngestorConfig snapshot. Always non-nil after
// construction.
func (s *Service) currentCfg() config.IngestorConfig {
if p := s.cfgPtr.Load(); p != nil {
return *p
}
return config.IngestorConfig{}
}

// SetListeners hot-swaps the shared listeners config. The next invocation
// of Run() reads the new value at the top of its body. An already-running
// RTMP listener keeps the old bind address until runtime restarts the
Expand Down Expand Up @@ -359,7 +340,7 @@ func (s *Service) Probe(ctx context.Context, input domain.Input) error {
if protocol.IsPushListen(input.URL) {
return fmt.Errorf("ingestor: probe unsupported for push-listen input %q", input.URL)
}
reader, err := NewPacketReader(input, s.currentCfg(), s.vods, s.buf, s.streamLookup)
reader, err := NewPacketReader(input, s.cfg, s.vods, s.buf, s.streamLookup)
if err != nil {
return err
}
Expand Down Expand Up @@ -405,7 +386,7 @@ func (s *Service) Stop(streamID domain.StreamCode) {
// ---- private ----

func (s *Service) startPullWorker(ctx context.Context, streamID domain.StreamCode, input domain.Input, bufferWriteID domain.StreamCode) error {
reader, err := NewPacketReader(input, s.currentCfg(), s.vods, s.buf, s.streamLookup)
reader, err := NewPacketReader(input, s.cfg, s.vods, s.buf, s.streamLookup)
if err != nil {
return fmt.Errorf("ingestor: create packet reader: %w", err)
}
Expand Down
24 changes: 1 addition & 23 deletions internal/ingestor/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,12 @@ func (b *capturingBus) Subscribe(_ domain.EventType, _ events.HandlerFunc) func(
func newTestService() *Service {
buf := buffer.NewServiceForTesting(8)
svc := &Service{
cfg: config.IngestorConfig{},
buf: buf,
bus: &capturingBus{},
registry: NewRegistry(),
workers: make(map[domain.StreamCode]*pullWorkerEntry),
}
ingCfg := config.IngestorConfig{}
svc.cfgPtr.Store(&ingCfg)
cp := config.ListenersConfig{}
svc.listenersPtr.Store(&cp)
return svc
Expand Down Expand Up @@ -122,26 +121,6 @@ func TestService_CurrentListenersZeroValueWhenUnset(t *testing.T) {
assert.False(t, got.RTMP.Enabled)
}

// ─── SetConfig / currentCfg ──────────────────────────────────────────────────

func TestService_SetConfigHotSwaps(t *testing.T) {
t.Parallel()
svc := newTestService()
// Default is the zero value (allow_private_targets off).
assert.False(t, svc.currentCfg().AllowPrivateTargets)

svc.SetConfig(config.IngestorConfig{AllowPrivateTargets: true})
assert.True(t, svc.currentCfg().AllowPrivateTargets,
"SetConfig must make the new allow_private_targets visible to the next reader")
}

func TestService_CurrentCfgZeroValueWhenUnset(t *testing.T) {
t.Parallel()
// cfgPtr never populated → fall back to zero value, never nil-deref.
svc := &Service{}
assert.False(t, svc.currentCfg().AllowPrivateTargets)
}

// ─── observer setters ────────────────────────────────────────────────────────

func TestService_SetPacketObserver(t *testing.T) {
Expand Down Expand Up @@ -297,7 +276,6 @@ func TestShouldFailoverImmediately(t *testing.T) {
{"TLS handshake failure", errors.New("tls: handshake failure"), true},
{"x509 cert untrusted", errors.New("x509: certificate signed by unknown authority"), true},
{"DNS resolution failure", errors.New("dial tcp: lookup foo: no such host"), true},
{"SSRF guard block", errors.New("hls: GET ...: dial tcp 10.0.0.1:80: 10.0.0.1: netguard: destination blocked: private/shared address"), true},
{"HTTP 401", errors.New("hls: fetch failed http 401 unauthorized"), true},
{"HTTP 403", errors.New("hls: http 403"), true},
{"HTTP 404", errors.New("hls: http 404 not found"), true},
Expand Down
7 changes: 1 addition & 6 deletions internal/ingestor/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,12 +622,7 @@ func shouldFailoverImmediately(err error) bool {
// every few seconds without surfacing as an input error).
if strings.Contains(msg, "x509:") ||
strings.Contains(msg, "tls:") ||
strings.Contains(msg, "no such host") ||
// SSRF egress guard rejected the destination (private/loopback/metadata
// with allow_private_targets off). A policy block never recovers by
// reconnecting, so surface it as an input error → immediate failover
// instead of spinning the reconnect loop forever.
strings.Contains(msg, "netguard:") {
strings.Contains(msg, "no such host") {
return true
}
m := httpStatusPattern.FindStringSubmatch(msg)
Expand Down
Loading