You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
NTNCellConfig.spec.provider.remoteControl.endpoint is a free-form host:port chosen by whoever writes the CR. When the runtime push path fires, the operator dials it from its own in-cluster network identity: pkg/provider/ocudu/wsclient.go does websocket.Dial(ctx, scheme+endpoint, …) with no egress control on the target. Admission validation (api/v1alpha1/ntncellconfig_types.go) only checks the shape — the host:port pattern, port range 1–65535, that a bracketed/all-numeric host is a valid IP, and DNS length limits. Nothing constrains which host or IP the operator will connect to.
So a principal who can write an NTNCellConfig (a namespaced privilege) but has no direct network access can point remoteControl.endpoint at an arbitrary in-cluster or link-local target and have the operator make the connection for them. This is a server-side request forgery surface on the runtime-control push path.
PR #219 already acknowledged this in its own body — "the operator still dials a CR-controlled host:port and can reach ClusterIP/loopback; an endpoint allowlist / Service reference is the real control, tracked separately" — and listed "endpoint allowlist" under Deferred. But no issue actually tracks it; it lives only in that PR body. This issue is that tracker.
Internal service / port probing oracle. The dial outcome is observable through the push condition: wsUnreachable (dial/TLS/timeout/5xx) surfaces as ProviderPushFailed, a definitive 3xx/4xx handshake rejection as ProviderPushRejected, and a spoken handshake as success. That lets a CR author distinguish "port closed/filtered" from "something answered HTTP" from "a WebSocket server is here" for any host:port — an information-disclosure oracle over the cluster network, distinct from the Secret existence/type oracle closed in security(runtime-push): refuse handshake redirects + harden remoteControl.tls Secrets (mitigation) #219 / fix(security): unify remoteControl.tls failure reason — close Secret existence oracle #296 (that one is about the Secret; this one is about the network).
WebSocket interaction with internal services. The operator sends a specific ntn_config_update JSON frame; a WS-speaking internal service can be poked with it.
Cloud metadata. Not reachable on a bare-metal cluster (no 169.254.169.254 IMDS), but applies to cloud deployments.
Existing partial mitigations on this path: the handshake refuses redirects (CheckRedirect → ErrUseLastResponse), the wss transport sets Proxy=nil, the payload is capped, and the Secret is same-namespace only. None of them constrains the initial dial target.
The target model here is inverted from the fetch path — do NOT reuse the private-IP denylist
pkg/netutil already has a mature SSRF guard used by the fetch path: IsPrivateIP (RFC1918 + loopback + link-local/metadata + multicast/reserved + NAT64/6to4-embedded IPv4, checked at dial time after DNS resolution, so it is rebinding-safe) and safeDialContext in NewSafeHTTPClient. It works for CelesTrak/Space-Track because the legitimate target there is a public host, so "block private" is the right rule.
That rule cannot be reused verbatim here. The legitimate remoteControl target is always private/in-cluster:
the documented default is a sidecar at 127.0.0.1:8001 (loopback), and
the cross-pod case is a ClusterIP (RFC1918 / the service CIDR).
IsPrivateIP blocks both. A "loopback/link-local/private denylist" would break the intended secure deployment. The correct control on this path is the opposite posture — an allowlist that permits only admin-approved gNB targets — which is also the design PR #219 pointed at. (DNS pinning drops in priority for the same reason: if the operator resolves an admin-approved Service/host rather than an attacker-supplied arbitrary hostname, DNS rebinding is largely moot.)
Proposed design
Reuse the project's established two-layer pattern (pkg/netutil/allowlist.go header + docs/design/metrics-source.md), adapted to the private-target model:
App-layer host allowlist via an operator flag (admin-controlled, tenant cannot modify — it is process config, not a CR field), reusing netutil.EndpointAllowlist. Empty = permit-all so it is backward compatible; when set, the operator refuses to dial any remoteControl.endpoint whose host is not listed, surfacing a deterministic condition (mirror ErrEndpointNotAllowed). This is the same shape as hardening: endpoint allow-list for NTNSlice.spec.metricsSource.prometheus (SSRF) #93.
Network-layer egress NetworkPolicy for the operator Pod. The operator currently ships no egress policy at all (config/network-policy/ only has allow-metrics-traffic.yaml, an ingress rule). A shipped default-deny-egress + explicit-allow sample (DNS, apiserver, and admin-filled gNB CIDRs) is the Kubernetes-native enforcement the codebase already names as the real IP-range control.
Optional: same-namespace ServiceReference + port as an ergonomic alternative to a free-form host, for the common in-cluster gNB case, so the operator resolves a Service it can see rather than an arbitrary string. Backward compatible alongside the existing endpoint.
Explicitly out of scope / not this issue:
A private-IP denylist on this path (inverts the target model; breaks sidecar-loopback).
With the allowlist flag set, a CR author cannot make the operator dial a remoteControl.endpoint host outside the admin-approved set; proven by a controller test.
Empty allowlist keeps current behavior (no regression for existing deployments).
A sample operator egress NetworkPolicy ships and is documented.
Background
NTNCellConfig.spec.provider.remoteControl.endpointis a free-formhost:portchosen by whoever writes the CR. When the runtime push path fires, the operator dials it from its own in-cluster network identity:pkg/provider/ocudu/wsclient.godoeswebsocket.Dial(ctx, scheme+endpoint, …)with no egress control on the target. Admission validation (api/v1alpha1/ntncellconfig_types.go) only checks the shape — thehost:portpattern, port range 1–65535, that a bracketed/all-numeric host is a valid IP, and DNS length limits. Nothing constrains which host or IP the operator will connect to.So a principal who can write an
NTNCellConfig(a namespaced privilege) but has no direct network access can pointremoteControl.endpointat an arbitrary in-cluster or link-local target and have the operator make the connection for them. This is a server-side request forgery surface on the runtime-control push path.PR #219 already acknowledged this in its own body — "the operator still dials a CR-controlled host:port and can reach ClusterIP/loopback; an endpoint allowlist / Service reference is the real control, tracked separately" — and listed "endpoint allowlist" under Deferred. But no issue actually tracks it; it lives only in that PR body. This issue is that tracker.
Why this is distinct from the existing issues
remoteControl.tlsSecret reads) is about which Secret the operator will read on the author's behalf. Its Option 3 ("endpoint pinning") narrows credential exfiltration — don't send a Secret meant for gNB A to gNB B — and only applies when a TLS Secret is configured. It does not cover the credential-less path (remoteControlwith notls, plaintextws://), where there is no Secret and no opt-in, yet the operator still dials an arbitrary target. The general egress-control / internal-probing surface is out of security: real per-CR authorization for remoteControl.tls Secret reads (confused-deputy follow-up to #219) #251's scope.NTNSlice.spec.metricsSource.prometheus. It is prior art for the mechanism, not coverage of this path.Attack surface
wsUnreachable(dial/TLS/timeout/5xx) surfaces asProviderPushFailed, a definitive 3xx/4xx handshake rejection asProviderPushRejected, and a spoken handshake as success. That lets a CR author distinguish "port closed/filtered" from "something answered HTTP" from "a WebSocket server is here" for anyhost:port— an information-disclosure oracle over the cluster network, distinct from the Secret existence/type oracle closed in security(runtime-push): refuse handshake redirects + harden remoteControl.tls Secrets (mitigation) #219 / fix(security): unify remoteControl.tls failure reason — close Secret existence oracle #296 (that one is about the Secret; this one is about the network).ntn_config_updateJSON frame; a WS-speaking internal service can be poked with it.tlsis configured.Existing partial mitigations on this path: the handshake refuses redirects (
CheckRedirect→ErrUseLastResponse), the wss transport setsProxy=nil, the payload is capped, and the Secret is same-namespace only. None of them constrains the initial dial target.The target model here is inverted from the fetch path — do NOT reuse the private-IP denylist
pkg/netutilalready has a mature SSRF guard used by the fetch path:IsPrivateIP(RFC1918 + loopback + link-local/metadata + multicast/reserved + NAT64/6to4-embedded IPv4, checked at dial time after DNS resolution, so it is rebinding-safe) andsafeDialContextinNewSafeHTTPClient. It works for CelesTrak/Space-Track because the legitimate target there is a public host, so "block private" is the right rule.That rule cannot be reused verbatim here. The legitimate
remoteControltarget is always private/in-cluster:127.0.0.1:8001(loopback), andIsPrivateIPblocks both. A "loopback/link-local/private denylist" would break the intended secure deployment. The correct control on this path is the opposite posture — an allowlist that permits only admin-approved gNB targets — which is also the design PR #219 pointed at. (DNS pinning drops in priority for the same reason: if the operator resolves an admin-approved Service/host rather than an attacker-supplied arbitrary hostname, DNS rebinding is largely moot.)Proposed design
Reuse the project's established two-layer pattern (
pkg/netutil/allowlist.goheader + docs/design/metrics-source.md), adapted to the private-target model:netutil.EndpointAllowlist. Empty = permit-all so it is backward compatible; when set, the operator refuses to dial anyremoteControl.endpointwhose host is not listed, surfacing a deterministic condition (mirrorErrEndpointNotAllowed). This is the same shape as hardening: endpoint allow-list for NTNSlice.spec.metricsSource.prometheus (SSRF) #93.config/network-policy/only hasallow-metrics-traffic.yaml, an ingress rule). A shipped default-deny-egress + explicit-allow sample (DNS, apiserver, and admin-filled gNB CIDRs) is the Kubernetes-native enforcement the codebase already names as the real IP-range control.ServiceReference+ port as an ergonomic alternative to a free-form host, for the common in-cluster gNB case, so the operator resolves a Service it can see rather than an arbitrary string. Backward compatible alongside the existingendpoint.Explicitly out of scope / not this issue:
Acceptance
remoteControl.endpointhost outside the admin-approved set; proven by a controller test.remoteControl.endpointAPI doc and the security(runtime-push): refuse handshake redirects + harden remoteControl.tls Secrets (mitigation) #219 "tracked separately" note point at this issue / the enforced control.References
pkg/provider/ocudu/wsclient.go,api/v1alpha1/ntncellconfig_types.go.metricsSource.prometheus).pkg/netutil/safeclient.go,pkg/netutil/allowlist.go.