From 515795e6ec5840ffbbeb676e30e401ad971a22ea Mon Sep 17 00:00:00 2001 From: ntthuan060102github Date: Mon, 15 Jun 2026 16:19:26 +0700 Subject: [PATCH] revert(ingest): remove ingest-side SSRF egress guard (operator decision) The dial-time SSRF guard on the HLS/HTTP-TS pull clients blocked private/RFC1918 targets unless an opt-in flag was set. On a server whose primary role is pulling from internal/LAN sources this is counterproductive: it caused a real failover freeze (an internal HLS input was blocked at dial, the worker reconnect-looped, no error surfaced), and it only ever covered the two HTTP-family pull protocols anyway. With the control plane now behind authentication, the unauthenticated-remote angle that made this high-severity is gone, so the ingest guard is removed. Removed: - netguard transport guard + redirect cap on the HLS and HTTP-TS clients - save-time input-URL validation in the stream handler - the allow_private_targets ingestor config flag (and its runtime hot-reload) - the egress-guard fail-fast classification in the ingest worker Kept: - the netguard package and its use by the webhook client (a hook must never reach the local admin API / metadata endpoint) - the connect-but-no-packets failover self-heal (independent) - the hooks file-root hot-reload (independent) Audit doc: the ingest SSRF finding is marked won't-fix / risk-accepted with rationale; the follow-up coverage/retroactivity bug is marked ignore. make check green (build + vet + lint + test -race). --- api/docs/docs.go | 4 ---- api/docs/swagger.json | 4 ---- api/docs/swagger.yaml | 9 -------- config/config.go | 8 ------- docs/audit/security-quality-audit.md | 4 +++- internal/api/handler/stream.go | 13 ----------- internal/ingestor/pull/hls.go | 16 +++----------- internal/ingestor/pull/httpts.go | 14 ++++-------- internal/ingestor/pull/httpts_test.go | 9 ++++---- internal/ingestor/reader.go | 2 +- internal/ingestor/service.go | 31 ++++++--------------------- internal/ingestor/service_test.go | 24 +-------------------- internal/ingestor/worker.go | 7 +----- internal/netguard/netguard.go | 13 ++++++----- internal/runtime/manager.go | 14 ------------ 15 files changed, 31 insertions(+), 141 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 7a8562c9..927f37da 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -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" diff --git a/api/docs/swagger.json b/api/docs/swagger.json index ef9587bb..d3d56d9e 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -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" diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 7e528e14..278bf871 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -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. diff --git a/config/config.go b/config/config.go index ba9ed81f..5d238cb8 100644 --- a/config/config.go +++ b/config/config.go @@ -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. diff --git a/docs/audit/security-quality-audit.md b/docs/audit/security-quality-audit.md index 23fce097..52cf5b72 100644 --- a/docs/audit/security-quality-audit.md +++ b/docs/audit/security-quality-audit.md @@ -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). diff --git a/internal/api/handler/stream.go b/internal/api/handler/stream.go index 9c3e9fce..2eec7c59 100644 --- a/internal/api/handler/stream.go +++ b/internal/api/handler/stream.go @@ -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" @@ -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 diff --git a/internal/ingestor/pull/hls.go b/internal/ingestor/pull/hls.go index beba171f..1447bb72 100644 --- a/internal/ingestor/pull/hls.go +++ b/internal/ingestor/pull/hls.go @@ -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" ) @@ -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. @@ -183,7 +181,6 @@ func NewHLSReader(input domain.Input, cfg config.IngestorConfig) *HLSReader { } return &HLSReader{ input: input, - cfg: cfg, maxBuf: maxBuf, } } @@ -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. diff --git a/internal/ingestor/pull/httpts.go b/internal/ingestor/pull/httpts.go index f2290a95..481f0ba1 100644 --- a/internal/ingestor/pull/httpts.go +++ b/internal/ingestor/pull/httpts.go @@ -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" ) @@ -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. @@ -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, }, } } diff --git a/internal/ingestor/pull/httpts_test.go b/internal/ingestor/pull/httpts_test.go index 8a080a2a..24294915 100644 --- a/internal/ingestor/pull/httpts_test.go +++ b/internal/ingestor/pull/httpts_test.go @@ -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 diff --git a/internal/ingestor/reader.go b/internal/ingestor/reader.go index da58fdc9..012f4d12 100644 --- a/internal/ingestor/reader.go +++ b/internal/ingestor/reader.go @@ -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) diff --git a/internal/ingestor/service.go b/internal/ingestor/service.go index e0f24ba4..db40e04c 100644 --- a/internal/ingestor/service.go +++ b/internal/ingestor/service.go @@ -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 @@ -102,6 +101,7 @@ func New(i do.Injector) (*Service, error) { vods := do.MustInvoke[*vod.Registry](i) svc := &Service{ + cfg: cfg, buf: buf, bus: bus, m: m, @@ -109,29 +109,10 @@ func New(i do.Injector) (*Service, error) { 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 @@ -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 } @@ -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) } diff --git a/internal/ingestor/service_test.go b/internal/ingestor/service_test.go index 8f6871ae..98a340f2 100644 --- a/internal/ingestor/service_test.go +++ b/internal/ingestor/service_test.go @@ -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 @@ -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) { @@ -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}, diff --git a/internal/ingestor/worker.go b/internal/ingestor/worker.go index 634e7b16..bb07c16f 100644 --- a/internal/ingestor/worker.go +++ b/internal/ingestor/worker.go @@ -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) diff --git a/internal/netguard/netguard.go b/internal/netguard/netguard.go index bb075f20..c5ad5e67 100644 --- a/internal/netguard/netguard.go +++ b/internal/netguard/netguard.go @@ -1,6 +1,5 @@ -// Package netguard provides SSRF egress guards shared by every component that -// makes outbound requests to operator- or content-supplied URLs (the ingest -// pull clients and the webhook HTTP client). +// Package netguard provides an SSRF egress guard for the webhook HTTP client, +// which makes outbound requests to operator-supplied URLs. // // The core is a dial-time IP filter installed via net.Dialer.Control. Control // runs with the RESOLVED ip:port for each connection attempt, so it sees the @@ -12,10 +11,14 @@ // address are rejected UNCONDITIONALLY. Loopback and private (RFC1918 / IPv6-ULA // / RFC6598) are each gated by the caller's Policy: // -// - Ingest pull: both gated by ingestor.allow_private_targets (default off → -// all internal blocked; on → trusted-network opt-in allows LAN + local). // - Webhooks: AllowPrivate (internal webhooks are legitimate) but NOT loopback // (a hook must never reach the local admin API). +// +// NOTE: ingest pull clients (HLS / HTTP-TS) historically applied this guard via +// IngestPolicy + ingestor.allow_private_targets, but ingest SSRF guarding was +// removed by operator decision — the server's primary role is pulling from +// internal/LAN sources, so blocking private targets was counterproductive (see +// the security audit, S-4). IngestPolicy / ValidateInputURL remain as helpers. package netguard import ( diff --git a/internal/runtime/manager.go b/internal/runtime/manager.go index ba45bcbc..5407cefb 100644 --- a/internal/runtime/manager.go +++ b/internal/runtime/manager.go @@ -265,20 +265,6 @@ func (m *Manager) diff(old, new *domain.GlobalConfig) { } } - // IngestorConfig hot-swap (allow_private_targets etc.). Stored atomically so - // the next reader built on a stream start / failover switch observes the new - // value. The diffService("ingestor", …) call below ALSO restarts the service - // when ingestor config changes (for the RTMP listener), but the atomic store - // here is what lets a freshly-switched input pick up the new SSRF policy even - // on a stream that doesn't trigger a service restart. - if configChanged(old.Ingestor, new.Ingestor) && m.deps.Ingestor != nil { - ingCfg := config.IngestorConfig{} - if new.Ingestor != nil { - ingCfg = *new.Ingestor - } - m.deps.Ingestor.SetConfig(ingCfg) - } - // HooksConfig hot-swap (file_root_dir etc.). Hooks have no listener to // restart, so the atomic store is the whole mechanism — the next hook // create/update validates against the new root and the next file delivery