Skip to content

security: SSRF egress control for remoteControl.endpoint (runtime-control WebSocket push) #299

Description

@thc1006

Background

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.

Why this is distinct from the existing issues

Attack surface

Existing partial mitigations on this path: the handshake refuses redirects (CheckRedirectErrUseLastResponse), 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:

  1. 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.
  2. 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.
  3. 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:

Acceptance

  • 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.
  • The remoteControl.endpoint API 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions