Skip to content

feat: add Go sandbox-router (drop-in replacement for Python)#838

Open
mastersingh24 wants to merge 14 commits into
kubernetes-sigs:mainfrom
mastersingh24:feat/sandbox-router-go
Open

feat: add Go sandbox-router (drop-in replacement for Python)#838
mastersingh24 wants to merge 14 commits into
kubernetes-sigs:mainfrom
mastersingh24:feat/sandbox-router-go

Conversation

@mastersingh24
Copy link
Copy Markdown
Contributor

@mastersingh24 mastersingh24 commented May 20, 2026

This PR adds a Go reimplementation of the sandbox-router at
clients/go/sandbox-router/, as a drop-in alternative to the existing
Python router at
clients/python/agentic-sandbox-client/sandbox-router/.

Why

The Python router is a small reverse proxy that fans HTTP traffic out to
sandbox pods by reading X-Sandbox-* headers and constructing the
internal Kubernetes DNS name. It works, but for enterprise deployments it
has gaps: no TLS, no mTLS, no metrics, no structured access logging, no
tracing, no per-request retry on dial failures.

This Go version closes those gaps while keeping the contract identical
so existing callers (the Go and Python SDKs, plus any direct HTTP clients)
keep working unchanged.

Scope

Drop-in protocol contract preserved:

  • Service name sandbox-router-svc on port 8080
  • X-Sandbox-ID / X-Sandbox-Namespace / X-Sandbox-Port /
    X-Sandbox-Pod-IP headers with the same defaults and validation rules
  • {"detail": "..."} JSON error shape
  • PROXY_TIMEOUT_SECONDS and CLUSTER_DOMAIN env-var support
  • GET /healthz returning {"status":"ok"} (used by Gateway
    HealthCheckPolicy)

Production controls added:

  • TLS termination with hot-reloading server certificate (fsnotify on the
    parent directory, safe under K8s Secret atomic-rename rotation)
  • Optional or required mTLS for clients
  • Structured access log per request (skips health/metrics endpoints)
  • Trace ↔ log correlation: trace_id and span_id baked into every
    per-request log line
  • Exponential-backoff dial retries for sandbox startup races
    (--upstream-max-retries; only dial-class failures are retried so
    request bodies are never replayed)
  • Prometheus metrics on /metrics and optional OTLP push via the
    OpenTelemetry → Prometheus bridge (--enable-otel-metrics)
  • OpenTelemetry tracing via OTLP gRPC (--enable-tracing); trace context
    propagated to the upstream sandbox
  • YAML config file (--config / SANDBOX_ROUTER_CONFIG); precedence is
    CLI > file > env > defaults
  • Graceful shutdown with readiness flip + parallel listener drain
  • Configurable request-body size limit (--max-request-body-bytes)
  • Built as a multi-arch distroless static image
    (gcr.io/distroless/static:nonroot)

Tested: unit tests across every package, integration tests
(-tags=integration) covering end-to-end proxy round-trip with body
streaming, healthz, all three mTLS modes with real handshakes, retry
success when backend comes up mid-window, retry give-up within budget.
All 14 cases from the Python router's test_sandbox_router.py have Go
equivalents.

Compatibility

The Python router source stays in the tree; the Go router builds as a
separate image named sandbox-router-go to avoid colliding with the
Python sandbox-router image (this required a small patch to
dev/tools/push-images). Operators opt in by switching their workload's
image. Existing Gateway / HTTPRoute / Service manifests don't change.

Example Kubernetes manifests are in
clients/go/sandbox-router/deploy/ (Deployment, Service, PDB,
NetworkPolicy, ServiceAccount) with a README walking through what to
tighten for production.

A local load-test harness is in dev/load-test/router/; reference
throughput / latency numbers are captured in the package README's
"Scaling guidance" section.

Files

New code under clients/go/sandbox-router/ (config, proxy, tlsutil,
observability, server packages + main + Dockerfile + deploy/), plus a
load-test harness under dev/load-test/router/. The Makefile gets a
build-sandbox-router target. go.mod promotes fsnotify and
golang.org/x/sync from indirect, and adds the OTLP metric exporter,
the OTel SDK metric package, and the OTel-to-Prometheus bridge.

Release Note

Added a Go implementation of the `sandbox-router` as a drop-in replacement for the Python router. The new image is
`sandbox-router-go` and preserves the `X-Sandbox-*` header contract and `sandbox-router-svc` Service name. Adds TLS termination
with hot-reloading certificates, optional or required mTLS, structured access logging, exponential-backoff dial retries for
upstream startup races, OpenTelemetry tracing and metrics export (alongside the existing Prometheus `/metrics` endpoint), an
optional YAML config file, and graceful shutdown. Example Kubernetes manifests are in `clients/go/sandbox-router/deploy/`. The
Python router remains available; operators opt in by switching their workload's image.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 20, 2026

Deploy Preview for agent-sandbox canceled.

Name Link
🔨 Latest commit 4bc00c6
🔍 Latest deploy log https://app.netlify.com/projects/agent-sandbox/deploys/6a1ea95092862500082954a5

@k8s-ci-robot
Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: mastersingh24
Once this PR has been reviewed and has the lgtm label, please assign soltysh for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot requested review from soltysh and vicentefb May 20, 2026 23:17
@k8s-ci-robot k8s-ci-robot added cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels May 20, 2026
@k8s-ci-robot
Copy link
Copy Markdown
Contributor

Hi @mastersingh24. Thanks for your PR.

I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@k8s-ci-robot k8s-ci-robot added the size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. label May 20, 2026
@janetkuo janetkuo requested a review from Copilot May 21, 2026 00:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a Go implementation of the sandbox-router (drop-in compatible with the existing Python router’s X-Sandbox-* header contract and sandbox-router-svc Service name), adding production features like TLS/mTLS, structured access logging, Prometheus + optional OTLP metrics export, tracing, dial retries, and graceful shutdown. It also updates build/release tooling and provides example Kubernetes manifests plus a local load-test harness.

Changes:

  • Added a new Go sandbox-router binary with proxying, retry/backoff, TLS cert hot-reload, probes, metrics, and tracing support.
  • Added deployment examples (deploy/) and a local load test harness (dev/load-test/router/).
  • Updated build tooling and dependencies (Makefile target, image push tooling overrides, go.mod/go.sum).

Reviewed changes

Copilot reviewed 49 out of 50 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
Makefile Adds build-sandbox-router and makes build include router build.
go.mod Adds/promotes deps needed for router (fsnotify, OTel metrics bridge/exporter, etc.).
go.sum Updates checksums for new/updated dependencies.
dev/tools/push-images Adds special-case build context + image name override for Go router Dockerfile.
dev/load-test/router/main.go Adds local load generator for throughput/latency measurements.
clients/go/sandbox-router/README.md Documents contract, flags, TLS/mTLS, metrics, tracing, scaling, and deployment.
clients/go/sandbox-router/main.go Implements router binary wiring: config, middleware, metrics/tracing init, TLS reload, server lifecycle.
clients/go/sandbox-router/Dockerfile Builds a static distroless multi-arch image for the Go router.
clients/go/sandbox-router/config/config.go Defines router config, defaults, and validation rules.
clients/go/sandbox-router/config/config_test.go Tests defaults/env/flags and config validation behavior.
clients/go/sandbox-router/config/flags.go Registers CLI flags and applies env-derived defaults for Python parity.
clients/go/sandbox-router/config/file.go Implements YAML config loading and pre-parse config file detection.
clients/go/sandbox-router/config/file_test.go Tests config file path selection and YAML application/validation.
clients/go/sandbox-router/config/testmain_test.go Adds goleak verification for config package tests.
clients/go/sandbox-router/observability/access_log.go Adds structured per-request access logging middleware.
clients/go/sandbox-router/observability/context.go Adds request-scoped labels/logger plumbing via context.
clients/go/sandbox-router/observability/metrics.go Defines Prometheus collectors + middleware and build info metric.
clients/go/sandbox-router/observability/otel_metrics.go Adds optional OTLP metrics push via OTel↔Prometheus bridge.
clients/go/sandbox-router/observability/tracing.go Adds per-request tracing middleware with trace↔log correlation.
clients/go/sandbox-router/observability/testmain_test.go Adds goleak verification for observability package tests.
clients/go/sandbox-router/proxy/errors.go Implements Python-compatible JSON error shape ({"detail": ...}).
clients/go/sandbox-router/proxy/errors_test.go Tests JSON error response formatting and error interface behavior.
clients/go/sandbox-router/proxy/headers.go Parses/validates X-Sandbox-* routing headers and defaults.
clients/go/sandbox-router/proxy/headers_test.go Unit tests for routing header parsing/namespace validation parity.
clients/go/sandbox-router/proxy/target.go Constructs upstream URL (DNS form vs pod-IP form) preserving path/query.
clients/go/sandbox-router/proxy/target_test.go Tests upstream URL construction behavior.
clients/go/sandbox-router/proxy/retry.go Adds dial-class retry transport with exponential backoff.
clients/go/sandbox-router/proxy/retry_test.go Unit tests for retry eligibility, backoff, and cancellation behavior.
clients/go/sandbox-router/proxy/proxy.go Core reverse proxy handler: header parse, upstream routing, tracing propagation, timeout bound.
clients/go/sandbox-router/proxy/proxy_integration_test.go Integration tests for round-trip proxying, streaming body, 400/502 behavior.
clients/go/sandbox-router/proxy/retry_integration_test.go Integration tests for retry success/give-up behavior and retry metrics.
clients/go/sandbox-router/proxy/healthz_integration_test.go Integration test for Python-compatible /healthz response shape.
clients/go/sandbox-router/proxy/testmain_test.go Adds goleak verification for proxy package tests.
clients/go/sandbox-router/server/probes.go Implements /healthz and /readyz with readiness flip for draining.
clients/go/sandbox-router/server/probes_test.go Unit tests for probe behavior and readiness transitions.
clients/go/sandbox-router/server/server.go Coordinates multiple listeners (proxy/http(s), metrics, probes) and shutdown behavior.
clients/go/sandbox-router/server/testmain_test.go Adds goleak verification for server package tests.
clients/go/sandbox-router/tlsutil/loader.go Adds fsnotify-based hot-reloading cert loader with debounce.
clients/go/sandbox-router/tlsutil/loader_test.go Unit tests for cert reloader initial load and hot reload behavior.
clients/go/sandbox-router/tlsutil/config.go Builds server TLS config and loads client CA pool for mTLS.
clients/go/sandbox-router/tlsutil/config_test.go Unit tests for TLS config mapping and CA pool loading errors.
clients/go/sandbox-router/tlsutil/testcerts_test.go Test helper for generating/writing self-signed certs.
clients/go/sandbox-router/tlsutil/tls_integration_test.go Integration tests for TLS + all mTLS modes with real handshakes.
clients/go/sandbox-router/tlsutil/testmain_test.go Adds goleak verification for tlsutil package tests.
clients/go/sandbox-router/deploy/README.md Describes example manifests and production hardening checklist.
clients/go/sandbox-router/deploy/deployment.yaml Example Deployment with probes, security context, and baseline args.
clients/go/sandbox-router/deploy/service.yaml Example Service preserving sandbox-router-svc name and proxy port mapping.
clients/go/sandbox-router/deploy/serviceaccount.yaml Example ServiceAccount (no RBAC by default).
clients/go/sandbox-router/deploy/pdb.yaml Example PDB for router availability during disruptions.
clients/go/sandbox-router/deploy/networkpolicy.yaml Example NetworkPolicy for ingress/egress constraints.

Comment thread sandbox-router/server/server.go
Comment thread clients/go/sandbox-router/server/server.go Outdated
Comment thread clients/go/sandbox-router/server/server.go Outdated
Comment thread sandbox-router/config/file.go
Comment thread clients/go/sandbox-router/config/flags.go Outdated
Comment thread sandbox-router/README.md
@barney-s
Copy link
Copy Markdown
Collaborator

Thanks for the PR. Impressive work. @mastersingh24
please refer to the KEP #758 and see if you are aligning with it. Feel free to provide comments on the KEP. Lets close that first.

@barney-s
Copy link
Copy Markdown
Collaborator

/hold for #758 agreement

@k8s-ci-robot k8s-ci-robot added the do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. label May 21, 2026
@ctm8788
Copy link
Copy Markdown

ctm8788 commented May 21, 2026

Thanks for pushing this forward. One thing I wanted to ask about before this becomes the preferred router implementation: should WebSocket / upgraded-connection forwarding be part of the compatibility contract?

A concrete use case is code-server / VS Code in-browser access. The initial HTML/static asset requests are normal HTTP, but the actual VS Code server connection uses a long-lived WebSocket. If the router does not preserve upgrade behavior correctly, the page can partially load and then fail with client-side disconnects, usually surfaced as WebSocket close 1006.

A few things that seem worth clarifying or testing:

  • Does the Go router intentionally support Connection: Upgrade / Upgrade: websocket pass-through via httputil.ReverseProxy?
  • Can we add an integration test with a WebSocket echo backend routed through the sandbox headers (X-Sandbox-ID, namespace, port), to make this an explicit contract?
  • Should ProxyTimeout apply to upgraded connections? Right now ServeHTTP wraps every request in context.WithTimeout(..., h.cfg.ProxyTimeout). For code-server, a 180s timeout would likely terminate an otherwise healthy WebSocket session. Maybe upgraded connections need a separate timeout or no normal request timeout after the handshake.
  • Related: should access logs / metrics treat 101 Switching Protocols as a successful proxied request, and avoid assuming normal response-body completion semantics?

@mastersingh24 mastersingh24 force-pushed the feat/sandbox-router-go branch from f083a1c to 6313dfb Compare May 22, 2026 09:54
@mastersingh24
Copy link
Copy Markdown
Contributor Author

Update: aligned with KEP-NNNN (#758)

Pushed 9 follow-up commits on feat/sandbox-router-go. Headline changes:

  • Pod-IP cache (informer keyed on Sandbox UID, server-side label-filtered on agents.x-k8s.io/sandbox-name-hash), with active invalidation on dial failure of a cached IP.
  • X-Sandbox-UID header added; resolution priority is X-Sandbox-Pod-IP > UID cache > DNS.
  • TokenReview authorizer behind --authz-mode=tokenreview, LRU + TTL cache keyed by SHA-256 hash of the token (raw tokens never sit in memory); audience filter; require-token transitional mode.
  • Promoted to top-level sandbox-router/ (was clients/go/sandbox-router/); binary at sandbox-router/cmd/main.go.

KEP requirements → status

KEP item Status Notes
Informer watching sandbox Pods server-side label-filtered
UID → PodIP in-memory map sync.RWMutex + map[types.UID]Entry
Only Ready Pods cached Ready→NotReady evicts
X-Sandbox-ID + X-Sandbox-UID request shape
Direct proxy to PodIP, bypass Service
UID read from Pod labels ⚠️ We read from controller-stamped OwnerReferences — the controller doesn't stamp UID as a label today. OwnerReferences carry the authoritative UID and the cluster enforces the linkage; worth deciding which the KEP prefers.
Active cache invalidation on connection error ErrorHandler calls Invalidate() only when source was cache
Hybrid DNS fallback (miss or cached-IP failure)
Short informer resync 10m default
Strict input validation namespace charset + port numeric
TokenReview integration
TokenReview cache with short TTL 30s default; negative results cached too; API errors cached briefly
system:auth-delegator binding shipped in deploy/rbac.yaml
// TODO: Add Authorization check (e.g. SubjectAccessReview) same TODO as the KEP — we authenticate but don't yet check per-sandbox ownership

Extras beyond the KEP

Area What we ship
TLS / mTLS HTTPS listener; mTLS off/optional/required; cert+key hot-reload via fsnotify (no pod restart on rotation)
Metrics Private Prometheus registry: requests, durations, inflight, upstream errors (classified dial/timeout/tls/eof), cache invalidations, authz decisions, cert reloads, dial retries, build_info
Tracing OTel via OTLP gRPC; auto-enabled on OTEL_EXPORTER_OTLP_ENDPOINT; trace context injected outbound; trace_id in every log line
OTel metrics push Same Prometheus registry, additionally pushed via OTLP — no double-instrumentation
Access logging One structured line per request with trace correlation; probes excluded
Reliability Dial-retry with exponential backoff (dial-class only — never replays bodies); pre-bound listeners; parallelized graceful shutdown with MarkUnready() first
TokenReview extras Audience filter (projected SA aud claim); negative-result caching (limits bad-token amplification); API-failure caching with shorter TTL; SHA-256 hashed cache keys
Authorizer interface Pluggable: AllowAll / TokenReview today; IdentityFromTLS helper ready for mTLS / SPIFFE authorizers
SDK fast path X-Sandbox-Pod-IP header skips both cache and DNS
Config file YAML config with kebab-case keys; env-var fallbacks (CLUSTER_DOMAIN, PROXY_TIMEOUT_SECONDS) for Python parity
Streaming FlushInterval: -1 (SSE / long-polling friendly)
Packaging Distroless-static multi-arch image; deploy manifests; kind smoke test

Open questions

  1. UID source: label vs OwnerReference. Should the controller stamp agents.x-k8s.io/sandbox-uid: <uid> as a Pod label (matches the KEP wording), or does the KEP move to "controller-stamped OwnerReference"? The OwnerReference approach has the nice property that the cluster enforces the link — can't be forged with a label edit.
  2. Per-sandbox authorization. Both this PR and the KEP have it as a TODO. Path forward needs an agreed identity contract on the Sandbox CR (owner label / annotation / SAR-style policy). Could be a separate KEP.
  3. Pod RBAC blast radius. K8s has no negative-namespace primitive, so the pods get/list/watch grant has to be cluster-wide. The runtime label selector keeps system Pods out of the cache, but the grant is broader than the behavior. Documented for auditors in rbac.yaml. Acceptable, or do we want a Kyverno/OPA carve-out pattern in the docs?
  4. Image name. Currently sandbox-router-go to coexist with the Python router image (sandbox-router). Once Python is deprecated, do we rename to plain sandbox-router?

Happy to split any of the extras out of the PR if the review prefers a tighter first cut.

@mastersingh24
Copy link
Copy Markdown
Contributor Author

@barney-s - couple things

@mastersingh24
Copy link
Copy Markdown
Contributor Author

@ctm8788 thanks for catching this — addressed in e1a5d3d. Status against your four sub-questions:

Sub-question Now
Connection: Upgrade / Upgrade: websocket pass-through via httputil.ReverseProxy ✅ Works (always did — ReverseProxy handles it natively since Go 1.12). Now explicitly tested.
Integration test with a WebSocket echo backend ✅ Added — see sandbox-router/proxy/websocket_integration_test.go. Three cases: round-trip through the router, upgrade outliving a tiny ProxyTimeout, and a non-upgrade request still respecting the timeout.
ProxyTimeout killing upgraded connections ✅ Fixed. The handler now detects Upgrade requests (same httpguts.HeaderValuesContainsToken check ReverseProxy uses internally) and skips the context.WithTimeout wrapper for them. Code-server-style sessions stay open until the client closes or TCP keepalive fails. Normal HTTP requests still bounded by --proxy-timeout.
Access logs / metrics treatment of 101 Switching Protocols Documented but unchanged. requests_total{code="101"} is recorded once the handshake completes; the duration histogram records the full lifetime of the upgraded connection. Called out in the README's new "WebSockets and other protocol upgrades" section so operators know to expect long-tail durations from real WS sessions.

The regression test pins ProxyTimeout=500ms, idles the WebSocket for 1.5s, then sends a frame — fails clearly with a hint message if the timeout-vs-upgrade carve-out gets broken later.

@mastersingh24
Copy link
Copy Markdown
Contributor Author

IPv6 PodIP support: fixed in 44d8e2d. Same root cause as the Copilot finding on #850 — both Resolve() and UpstreamURL() were doing host + ":" + strconv.Itoa(port), which produces an unparseable string for bare IPv6 literals coming out of Pod.Status.PodIP on dual-stack / IPv6-only clusters (::1:8080 is ambiguous, net/http rejects). Swapped both to net.JoinHostPort and added table tests (loopback, full v6) plus a cache-hit + override IPv6 regression test.

@ctm8788
Copy link
Copy Markdown

ctm8788 commented May 22, 2026

Nice! I just wanted to confirm because the Python version definitely does not support websockets properly.

@mastersingh24
Copy link
Copy Markdown
Contributor Author

Port validation hardening: fixed in 1dda38a. Carrying over the Copilot finding from #850X-Sandbox-Port was only being rejected on strconv.Atoi failure, so 0 / negatives / >65535 all sailed through into the upstream URL and surfaced as opaque 502s. Tightened ParseSandboxHeaders to require [1, 65535] and added table cases for the rejected values plus both legal boundaries.

@barney-s
Copy link
Copy Markdown
Collaborator

Thanks for pushing this forward. One thing I wanted to ask about before this becomes the preferred router implementation: should WebSocket / upgraded-connection forwarding be part of the compatibility contract?

A concrete use case is code-server / VS Code in-browser access. The initial HTML/static asset requests are normal HTTP, but the actual VS Code server connection uses a long-lived WebSocket. If the router does not preserve upgrade behavior correctly, the page can partially load and then fail with client-side disconnects, usually surfaced as WebSocket close 1006.

A few things that seem worth clarifying or testing:

  • Does the Go router intentionally support Connection: Upgrade / Upgrade: websocket pass-through via httputil.ReverseProxy?
  • Can we add an integration test with a WebSocket echo backend routed through the sandbox headers (X-Sandbox-ID, namespace, port), to make this an explicit contract?
  • Should ProxyTimeout apply to upgraded connections? Right now ServeHTTP wraps every request in context.WithTimeout(..., h.cfg.ProxyTimeout). For code-server, a 180s timeout would likely terminate an otherwise healthy WebSocket session. Maybe upgraded connections need a separate timeout or no normal request timeout after the handshake.
  • Related: should access logs / metrics treat 101 Switching Protocols as a successful proxied request, and avoid assuming normal response-body completion semantics?

we have working example that uses custom router (go based) and sandboxes running vscode here:
https://github.com/gke-labs/gemini-for-kubernetes-development/blob/main/repo-agent/pkg/api/handlers_sandbox_proxy.go#L13

@mastersingh24 mastersingh24 force-pushed the feat/sandbox-router-go branch from 1dda38a to 6d8813e Compare May 30, 2026 10:05
@mastersingh24
Copy link
Copy Markdown
Contributor Author

mastersingh24 commented May 30, 2026

@barney-s

Thanks for the pointer — pulled your handler down. Two enhancements landed in d04bfb5:

1. Origin stripping on upgrade. Followed your handler's pattern: WebSocket backends that validate Origin == Host for CSRF protection (vscode-server, Jupyter) reject the upgrade with 1006 when the router rewrites Host but leaves Origin pointing at the router's external hostname. We now drop Origin only on upgrade requests so normal HTTP CORS preflights stay intact. Regression test sets Origin on the dial and asserts the backend sees it empty; a guard test confirms non-upgrade traffic preserves it.

2. X-Forwarded-Host / X-Forwarded-Proto / X-Forwarded-For. Set on every outbound request via httputil.ReverseProxy's SetXForwarded helper. Browser-facing backends like vscode-server need these to build correct self-links and redirects when sitting behind a host-rewriting proxy.

README's "WebSockets and other protocol upgrades" section documents both with rationale.

mastersingh24 added a commit to mastersingh24/agent-sandbox that referenced this pull request May 30, 2026
Mirror the browser-backend compatibility fix that just landed on the
from-scratch Go router (kubernetes-sigs#838 d04bfb5), in the shapes that make sense
for the ext_proc design.

1. Origin stripping on upgrade — in the Go handler. When readHeaders
   sees BOTH Connection: Upgrade AND a non-empty Upgrade header
   (matching the predicate httputil.ReverseProxy uses internally),
   the resulting HeaderMutation gains RemoveHeaders: ["origin"]
   alongside the existing dst-host SetHeaders. Envoy normalizes
   header keys to lowercase, so the lowercase "origin" is what Envoy
   actually removes. Backends that validate Origin == Host for CSRF
   (vscode-server, Jupyter) no longer reject the upgrade with a 1006
   close. Non-upgrade requests are unaffected so CORS preflights and
   any Origin-aware non-WebSocket logic still work.

2. X-Forwarded-Host — in the Envoy config. One-liner
   request_headers_to_add at the virtual_host level with value
   "%REQ(:AUTHORITY)%". X-Forwarded-For / -Proto were already free
   from use_remote_address: true; -Host doesn't come with that
   setting and needs to be wired separately.

Tests: TestHandle_StripsOriginOnUpgrade (asserts the mutation's
RemoveHeaders contains "origin" while the dst-host SetHeaders is
preserved), TestHandle_NonUpgradePreservesOrigin (guards the
non-upgrade path), TestReadHeaders_UpgradeDetection table covering
the 8 corner cases of the upgrade predicate. README documents both
behaviors and the rationale.
Copy link
Copy Markdown
Collaborator

@aditya-shantanu aditya-shantanu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough port — the observability/TLS/retry work looks solid. One substantive gap I wanted to flag on the "drop-in replacement" claim: the Go router drops two input-validation checks the Python router performs, and with the default `AllowAll` authorizer there is nothing else gating the request path.

1. X-Sandbox-Pod-IP is dialed unvalidated (SSRF).
In proxy/headers.go, ParseSandboxHeaders stores PodIP: h.Get(HeaderSandboxPodIP) verbatim, and proxy/resolve.go Resolve() uses it directly as the dial host (SourcePodIP). The Python router (sandbox_router.py) parses this header with ipaddress.ip_address() and rejects loopback / link-local / multicast / unspecified addresses:

ip = ipaddress.ip_address(pod_ip)
if ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
    raise HTTPException(status_code=400, detail="Invalid target IP address.")

As written, a caller can set X-Sandbox-Pod-IP: 169.254.169.254 (cloud metadata) or 127.0.0.1 and the router will proxy to it. Since NewHandler defaults to authz.AllowAll{} to preserve the Python no-auth contract, the default deployment has no compensating control. Recommend porting the same IP-class rejection (net.ParseIP + IsLoopback()/IsLinkLocalUnicast()/IsLinkLocalMulticast()/IsMulticast()/IsUnspecified(), and likely also link-local/ULA) before accepting the header.

2. X-Sandbox-ID is not validated.
Python validates sandbox_id against a DNS-label regex ("to prevent DNS injection and directory traversal style attacks") before interpolating it into the FQDN. The Go path validates only namespace (validNamespace) and port; ID flows straight into t.ID + "." + t.Namespace + ".svc." + clusterDomain in Resolve(). Worth applying the same DNS-label check to ID for parity.

The KEP-requirements table marks "Strict input validation" as done with "namespace charset + port numeric" — these two cases appear to be the missing pieces relative to the Python behavior this is meant to replace.

Re-implements the Python sandbox-router
(clients/python/agentic-sandbox-client/sandbox-router/) in Go with the
controls needed for enterprise deployments. Preserves the X-Sandbox-*
header contract, Service name (sandbox-router-svc), JSON error shape,
and PROXY_TIMEOUT_SECONDS / CLUSTER_DOMAIN env vars so existing callers
and Gateway/HTTPRoute resources keep working.

The Python router source stays in the tree until deprecation is
formalized. The Go router builds as a separate image named
"sandbox-router-go" to avoid colliding with the Python "sandbox-router"
image.

Features beyond the Python router:
  - TLS termination with hot-reloading server cert (fsnotify on the
    parent dir, atomic-rename safe for Kubernetes Secret projection)
  - Optional or required mTLS for clients
  - Prometheus metrics on /metrics and OTLP push via OTel-Prometheus
    bridge (--enable-otel-metrics)
  - OpenTelemetry tracing via OTLP gRPC (--enable-tracing); spans carry
    sandbox.id / sandbox.namespace attributes; trace context propagated
    to the upstream sandbox
  - Trace-log correlation: trace_id and span_id baked into every
    per-request log line
  - Dial-retry with exponential backoff for upstream startup races
    (--upstream-max-retries; only dial-class failures retried so request
    bodies are never replayed)
  - Structured access logging (--access-log, defaults on; skips
    /healthz, /readyz, /metrics)
  - YAML config file (--config / SANDBOX_ROUTER_CONFIG); precedence is
    CLI > file > env > defaults
  - Graceful shutdown with readiness flip + parallel listener drain
  - Configurable request-body size limit (--max-request-body-bytes)
  - Multi-arch distroless static image (gcr.io/distroless/static:nonroot)

Layout:
  clients/go/sandbox-router/    library packages + main.go + Dockerfile
    config/                     Config struct, flags, YAML loader
    proxy/                      Handler, headers, target, errors, retry
    tlsutil/                    Hot-reloading cert + tls.Config builder
    observability/              Prometheus + OTel + access log + tracing
    server/                     Four HTTP servers (HTTP, HTTPS, metrics,
                                probes) with parallel-drain shutdown
    deploy/                     Example K8s manifests (Deployment,
                                Service, PDB, NetworkPolicy, RBAC)
    README.md                   Architecture, contract, flags, scaling
  dev/load-test/router/         Self-contained Go load harness

Modified files:
  Makefile                — adds build-sandbox-router target
  dev/tools/push-images   — uses repo root as build context for the
                            sandbox-router Dockerfile; image named
                            "sandbox-router-go"
  go.mod / go.sum         — promotes fsnotify and x/sync from indirect;
                            adds OTLP metric exporter, OTel SDK metric,
                            and the OTel-Prometheus bridge

Tests:
  - Unit tests across all packages (headers, target, errors, retry, cert
    reloader, TLS config, config validation, YAML loader, probes,
    access log fields)
  - Integration tests gated by //go:build integration: end-to-end proxy
    round-trip + body streaming, healthz, all three mTLS modes with
    real handshakes, retry succeeds when backend comes up mid-window,
    retry gives up within budget
  - goleak.VerifyTestMain in every test-bearing package
  - All 14 Python test_sandbox_router.py cases have Go equivalents

Verification:
  - go build ./... clean
  - go test ./clients/go/sandbox-router/... — green
  - go test -tags=integration ./clients/go/sandbox-router/... — green
  - make lint-go — 0 issues
  - Live load-test numbers captured in clients/go/sandbox-router/README.md

Out of scope (called out in README for future work):
  - Per-sandbox authorization (deferred until the CRD-level identity
    contract is designed)
  - Rate limiting and circuit breaker (Envoy handles these well; the
    README's "When to consider Envoy instead" section discusses the
    architectural trade-off)
  - CA bundle hot-reload (server cert hot-reloads; client CA does not)
  - WebSocket / hijacked-connection graceful drain
Two related fixes in server.Run() raised in PR review:

1. /readyz used to flip to 200 immediately after spawning the listener
   goroutines, before they had necessarily called Listen + bound their
   ports. On a fresh pod the LB could therefore briefly route traffic to
   a port that wasn't accepting yet. The new flow calls net.Listen
   synchronously for every listener up front, closes any successfully
   bound listeners if a later bind fails, and only MarkReady() after
   every port is bound. Bind failures now surface as a direct error from
   Run() instead of from an async goroutine.

2. The docstring promised parallel shutdown but the code looped through
   the listeners serially, so one slow drain could consume the whole
   --shutdown-timeout budget. Shutdown calls are now driven from a
   sync.WaitGroup (Go 1.25 WaitGroup.Go form) so they run concurrently.

Added server_test.go covering: bind-failure surfaces synchronously and
keeps readiness false; partial-bind failure releases the earlier port
for retry; happy path flips readiness only after all binds succeed and
clears it on shutdown.
- config: --config flag now wins over SANDBOX_ROUTER_CONFIG env var,
  matching the documented overall precedence (CLI > file > env >
  defaults). The previous order returned env first, contradicting the
  docstring. Flipped the codified test case.

- config/flags: drop stale KUBECONFIG mention from RegisterFlags
  docstring. The --kubeconfig flag was removed earlier in development
  when it collided with controller-runtime's auto-registered one; the
  comment was left behind.

- observability: tracing and OTel metrics push are now auto-enabled
  when OTEL_EXPORTER_OTLP_ENDPOINT (or the signal-specific
  _TRACES_ENDPOINT / _METRICS_ENDPOINT variants) is set. The README and
  flag help text already implied this behavior; the implementation now
  matches. Explicit --enable-tracing=false / --enable-otel-metrics=false
  still wins, detected via flag.Visit so we can distinguish "user did
  not set" from "user explicitly set to default value." Added
  TestApplyPostParseEnvDefaults covering 8 scenarios (generic endpoint,
  signal-specific endpoints, explicit false-override, explicit
  true-with-no-env, empty env value treated as unset).

- README: updated the flag table to call out the auto-enable behavior
  explicitly.
Introduce an in-process Pod informer cache keyed by Sandbox CR UID so
requests carrying the new X-Sandbox-UID header dial the live PodIP
directly, bypassing DNS. Resolution priority remains stable: explicit
X-Sandbox-Pod-IP > UID cache hit > DNS form.

The informer is server-side filtered on the
agents.x-k8s.io/sandbox-name-hash label so memory and API traffic scale
with sandbox count, not cluster size. Only Pods that pass PodReady=True
with a non-empty PodIP are stored; Pods that flip out of Ready are
evicted so traffic does not get steered at degraded backends.

Add active cache invalidation per the KEP: when the proxy dials a
cached IP and the dial fails, the entry is evicted immediately so the
next request for the same UID falls through to DNS instead of retrying
the stale IP. A new sandbox_router_cache_invalidations_total counter
tracks how often this fires.

The cache is opt-in via --cache-enabled (default off). When enabled,
the router blocks readiness on the initial Pod LIST so a misconfigured
RBAC fails fast at startup rather than silently degrading to DNS-only
service. Pod get/list/watch RBAC ships in deploy/rbac.yaml; the
example deployment.yaml turns the flag on.
Introduce package authz with an Authorizer interface, the no-op
AllowAll default, and helpers for the two credential shapes the router
will see in practice: TLS client certs (IdentityFromTLS pulls
SPIFFE → DNS SAN → CN with O groups) and Bearer tokens
(BearerTokenFromRequest extracts and trims the Authorization header).

The proxy now calls Authorize(r, ns, sandbox) after header parsing and
before resolving the upstream. ErrUnauthenticated maps to 401,
ErrForbidden to 403, anything else to 500; any unknown error is treated
as an authorizer bug rather than a silent forbid. Per-decision metrics
land in sandbox_router_authz_decisions_total.

AllowAll is the only implementation wired into main.go today so the
default behavior is unchanged. The TokenReview-backed authorizer that
satisfies the KEP's authn requirement ships in the next commit.
Implement the KEP-NNNN authentication requirement: the router can now
validate every inbound request's Authorization: Bearer token against
the cluster's authentication.k8s.io/v1.TokenReview API. Decisions are
cached in an LRU keyed by SHA-256 hash of the token (raw tokens never
sit in memory) for a configurable TTL — short enough to catch
revocations, long enough to keep authn off the hot path.

Negative TokenReview results are cached at the full TTL so a flood of
bad tokens does not amplify to apiserver load. API failures are cached
briefly (TTL/3, minimum 1s) so a flapping apiserver self-heals without
getting pummeled.

Wired via --authz-mode=tokenreview with related --authz-tokenreview-*
flags for TTL, cache size, audience filter, and require-token mode.
Default remains allow-all to preserve the Python router contract.
deploy/rbac.yaml adds the standard system:auth-delegator binding
required by tokenreviews.authentication.k8s.io.

Scope note: this is authentication only — the resulting principal is
not yet checked against per-sandbox ownership. That tightening needs
an agreed identity contract on the Sandbox CR and is tracked as
follow-up.
…config flag

controller-runtime's pkg/client/config registers a --kubeconfig flag in
its package init. Our own RegisterFlags would panic on re-registration
when main.go imports ctrl. Detect the existing flag, skip our duplicate
StringVar, and pull the value into c.Kubeconfig in
ApplyPostParseEnvDefaults so the rest of the code reads from a single
field regardless of which package owns the flag.

Add clients/go/sandbox-router/dev/smoke-test/run.sh: end-to-end
verification on a real kind cluster covering DNS-form routing, UID
cache hit, metrics exposure, active cache invalidation on pod
deletion, and tokenreview-mode (rejecting tokenless requests with 401
and accepting a fresh projected SA token). Idempotent; tears its
cluster down on exit unless KEEP_CLUSTER=1. Documented for use as the
manual release-gate check rather than per-PR CI.
Add an auditor-facing note to deploy/rbac.yaml (and pointers from both
READMEs) covering the gap between the RBAC grant and the runtime
behavior: the grant has to be cluster-wide because K8s RBAC has no
negative-namespace primitive, but the informer's server-side label
selector (agents.x-k8s.io/sandbox-name-hash) means system-namespace
Pods are never returned and never cached. Document the two ways to
tighten the grant itself (enumerated per-namespace RoleBindings,
Kyverno/OPA policy) for deployments that need RBAC and behavior to
match exactly.
The KEP positions the sandbox-router as a top-level component of the
project. Move clients/go/sandbox-router/ → sandbox-router/ (matching
the controller's top-level layout) with the binary entry at
sandbox-router/cmd/main.go.

Mechanical rename only — no behavior change:
  - `git mv` every file; main.go relocates from the package root into
    cmd/ so the library packages and the binary stay clearly separated
  - all `sigs.k8s.io/agent-sandbox/clients/go/sandbox-router/...`
    import paths rewritten to `sigs.k8s.io/agent-sandbox/sandbox-router/...`
  - Dockerfile narrows its COPY set to the dirs it actually needs
    (sandbox-router, api, extensions/api, internal) and builds
    ./sandbox-router/cmd
  - Makefile build-sandbox-router target points at ./sandbox-router/cmd
  - dev/tools/push-images go_router_dir special case follows the move;
    image name stays sandbox-router-go to avoid colliding with the
    Python router still living at clients/python/...
  - smoke-test paths and REPO_ROOT computation updated
  - README cross-links updated
  - smoke test gains a wait_router_serving probe (actively reaches
    /healthz via the Service VIP) used after the tokenreview rollout
    where iptables/IPVS plumbing can lag the endpoints update

Full unit + integration test suites green; kind smoke test (6/6) green.
The ServeHTTP handler wrapped every request in
context.WithTimeout(ctx, ProxyTimeout), which silently tore down
WebSocket / Upgrade connections at the timeout boundary. With the
180s default a healthy code-server editing session (single long-lived
WebSocket per session) would surface to the client as a WebSocket
close 1006 at the 3-minute mark.

Detect Upgrade requests with httpguts.HeaderValuesContainsToken (same
test httputil.ReverseProxy uses internally) and skip the WithTimeout
wrapper for them; once the 101 handshake is done, TCP keepalive is
the liveness signal, not our handler context. Normal HTTP requests
continue to be bounded by ProxyTimeout.

Add three integration tests covering: round-trip through the router
to a WebSocket echo backend, an upgraded connection outliving a
deliberately-tiny ProxyTimeout (the regression test for the reviewer
comment), and a non-upgrade request still getting cut off by the
timeout (guards against the carve-out being too broad).

Documents the carve-out and the 101 metric semantics in the README.
Resolve() and UpstreamURL() were concatenating "host + \":\" + port"
when building the upstream URL, which produces an unparseable string
for IPv6 Pod IPs. Pod.Status.PodIP is a bare IPv6 literal on
dual-stack or IPv6-only clusters, and "::1:8080" is ambiguous with the
address itself — net/http rejects the URL before the request leaves
the router.

Swap both call sites to net.JoinHostPort, which brackets IPv6
literals per RFC 3986 and is a no-op for IPv4 / DNS names. Add table
cases (loopback, full v6) to TestUpstreamURL and a
TestResolveBracketsIPv6PodIP that exercises both the cache-hit and
the X-Sandbox-Pod-IP override paths.

Same bug, same fix as b841c55 on the ext_proc branch.
ParseSandboxHeaders only rejected non-numeric X-Sandbox-Port values
(strconv.Atoi failure). Zero, negative, and out-of-range values
sailed through and ended up in the upstream URL via net.JoinHostPort
as a syntactically valid but semantically junk host:port that
surfaces as an opaque 502 once net/http tries to dial it.

Tighten the bound to [1, 65535] and add table cases for the four
rejected values (0, -1, 65536) and the two accepted boundaries
(1, 65535).

Same fix as 6437679 on the ext_proc branch.
Two enhancements prompted by the WebSocket / vscode-server reference
implementation at gke-labs/gemini-for-kubernetes-development:

1. Origin stripping on Upgrade. WebSocket backends that validate
   Origin == Host for CSRF protection (vscode-server, Jupyter, and
   friends) reject the upgrade with WebSocket close 1006 when the
   router rewrites Host but leaves Origin pointing at the router's
   external hostname. Drop Origin on upgrade requests so the backend
   sees "no Origin assertion available" — CSRF-aware backends
   typically allow that path for non-browser callers, and vscode /
   Jupyter work as-is. Normal HTTP requests preserve Origin so CORS
   preflights stay intact.

2. X-Forwarded-Host / -Proto / -For on every outbound request via
   httputil.ReverseProxy's SetXForwarded helper. Browser-facing
   backends (Jupyter, vscode-server) need these to construct correct
   self-links and redirects when sitting behind a proxy that
   rewrites Host.

Reuse the upgrade bool the timeout block already computes so the
Rewrite callback and the WithTimeout carve-out stay in sync.

Tests: TestIntegration_WebSocketStripsOriginOnUpgrade (sets Origin
on the dial, asserts the backend sees it empty),
TestIntegration_NonUpgradePreservesOrigin (guard against the strip
leaking into regular HTTP and breaking CORS),
TestIntegration_XForwardedHeadersSet (Host matches router VIP,
Proto=http for plain-HTTP, For non-empty). README "WebSockets" section
documents both behaviors.
@mastersingh24 mastersingh24 force-pushed the feat/sandbox-router-go branch from d04bfb5 to 651c119 Compare June 2, 2026 09:33
mastersingh24 added a commit to mastersingh24/agent-sandbox that referenced this pull request Jun 2, 2026
Mirror the browser-backend compatibility fix that just landed on the
from-scratch Go router (kubernetes-sigs#838 d04bfb5), in the shapes that make sense
for the ext_proc design.

1. Origin stripping on upgrade — in the Go handler. When readHeaders
   sees BOTH Connection: Upgrade AND a non-empty Upgrade header
   (matching the predicate httputil.ReverseProxy uses internally),
   the resulting HeaderMutation gains RemoveHeaders: ["origin"]
   alongside the existing dst-host SetHeaders. Envoy normalizes
   header keys to lowercase, so the lowercase "origin" is what Envoy
   actually removes. Backends that validate Origin == Host for CSRF
   (vscode-server, Jupyter) no longer reject the upgrade with a 1006
   close. Non-upgrade requests are unaffected so CORS preflights and
   any Origin-aware non-WebSocket logic still work.

2. X-Forwarded-Host — in the Envoy config. One-liner
   request_headers_to_add at the virtual_host level with value
   "%REQ(:AUTHORITY)%". X-Forwarded-For / -Proto were already free
   from use_remote_address: true; -Host doesn't come with that
   setting and needs to be wired separately.

Tests: TestHandle_StripsOriginOnUpgrade (asserts the mutation's
RemoveHeaders contains "origin" while the dst-host SetHeaders is
preserved), TestHandle_NonUpgradePreservesOrigin (guards the
non-upgrade path), TestReadHeaders_UpgradeDetection table covering
the 8 corner cases of the upgrade predicate. README documents both
behaviors and the rationale.
Reviewer @aditya-shantanu flagged two missed checks the Python router
performs, and an audit against sandbox_router.py found two more.
Close all four to deliver on the "drop-in replacement" claim.

1. X-Sandbox-Pod-IP class check (SSRF).
   Python rejects loopback / link-local / multicast / unspecified
   addresses via ipaddress.ip_address(). We were storing the header
   verbatim and dialing it. A caller could set X-Sandbox-Pod-IP:
   169.254.169.254 to reach cloud metadata, 127.0.0.1 to hit the
   router pod's own loopback, etc. — and with AllowAll as the default
   authorizer there was no compensating control. validPodIP uses
   net.ParseIP + the equivalent class checks.

2. X-Sandbox-ID DNS-label validation.
   Python validates the ID against a DNS-1123 label regex
   specifically to block DNS injection ("foo.evil.com") and
   traversal-style ("foo/bar") inputs that would otherwise be
   interpolated into "<id>.<ns>.svc.<cluster-domain>". We validated
   namespace and port but not ID. Now applied to both ID and
   namespace through a shared validDNSLabel helper that matches
   Python's ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ with the 63-char cap.

3. Tightened namespace validation.
   The old validNamespace was more permissive than Python's check
   (allowed uppercase, leading/trailing hyphens, unbounded length).
   K8s itself won't accept namespaces matching the loose rule, so
   the practical risk is just routing to FQDNs that can't exist —
   but tightening to the same DNS-1123 label rule used for ID keeps
   the validation surface uniform and matches Python exactly.

4. Authorization header strip.
   Python drops Authorization right next to Host before forwarding.
   We were forwarding it verbatim. With --authz-mode=tokenreview the
   router consumes the caller's K8s bearer token; leaking it to the
   sandbox would let any sandbox impersonate the caller against the
   K8s API or any other Bearer-protected service. Done in the
   Rewrite callback alongside the existing Host strip.

Loopback escape hatch: --allow-loopback-pod-ip (default false). The
sidecar deployment shape (sandbox shares a Pod with the router, so
127.0.0.1 is the correct dial target) is a legitimate use case and
integration tests need it too. Link-local, multicast, and unspecified
classes stay rejected regardless of this flag.

ParseSandboxHeaders gains a ParseOptions struct so the loopback
toggle threads cleanly through without a positional bool. The
Handler reads h.cfg.AllowLoopbackPodIP and passes it through.

Tests: existing integration tests flip AllowLoopbackPodIP=true after
config.Defaults() since httptest binds to 127.0.0.1. New cases cover
DNS-label rejection on ID (dot, slash, underscore, uppercase, leading
/ trailing hyphen), Pod-IP class rejection (loopback v4/v6,
169.254.169.254, fe80::1, 224.0.0.1, 0.0.0.0, ::), routable accept
(v4 + v6), and the loopback flag's effect. New TestValidDNSLabel +
TestValidPodIP cover the helpers directly.
TestIntegration_AuthorizationStrippedFromUpstream asserts the
Authorization header does not reach the backend.

README documents the new validation surface and error responses,
adds the --allow-loopback-pod-ip flag row, and notes the
Authorization strip in the routing-contract paragraph.
mastersingh24 added a commit to mastersingh24/agent-sandbox that referenced this pull request Jun 2, 2026
…st trust

Mirror the parity fixes that just landed on PR kubernetes-sigs#838, in the shapes
that fit the ext_proc design. Together with the per-PR ingress strip,
this closes the four-finding security review from @aditya-shantanu.

1. X-Sandbox-ID DNS-label validation. The Python router runs ID
   through ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ (max 63 chars) so a caller
   can't interpolate extra DNS components into "<id>.<ns>.svc.
   <cluster-domain>" via the DNS-form fallback. We validated namespace
   and port but not ID. Now applied to both ID and namespace through
   a shared validDNSLabel helper.

2. Tightened namespace validation. The previous validNamespace allowed
   uppercase, leading/trailing hyphens, and unbounded length. K8s
   itself rejects all three, so the practical risk was just routing
   to FQDNs that can't exist — but tightening to the same DNS-1123
   rule used for ID keeps the validation surface uniform and matches
   Python exactly. validNamespace removed in favor of validDNSLabel.

3. Authorization header strip. Always add "authorization" to the
   HeaderMutation.RemoveHeaders so the sandbox never sees the
   caller's bearer credential. Without this, a sandbox could
   impersonate the caller against the K8s API or any other
   Bearer-protected service. Matches the Python router and the same
   fix on kubernetes-sigs#838.

4. x-envoy-original-dst-host ingress strip (defense in depth). A new
   envoy.filters.http.header_mutation filter runs BEFORE ext_proc in
   the HCM chain and removes any client-supplied
   x-envoy-original-dst-host. ext_proc still always sets it via
   HeaderMutation, so after the filter chain the value reaching the
   ORIGINAL_DST cluster is provably the one ext_proc wrote — or
   absent if a future route disables ext_proc, in which case the
   cluster fails closed with 503 rather than dispatching to whatever
   the client asked for. Without this, the security of the data path
   would rest on "ext_proc is enabled on every route", which the
   existing /healthz route already demonstrates is not a load-
   bearing assumption.

Tests: TestHandle_InvalidIDRejected covers the six classes of
DNS-injection / traversal inputs (dot, slash, underscore, uppercase,
leading hyphen, trailing hyphen). TestHandle_AlwaysStripsAuthorization
asserts the RemoveHeaders mutation contains "authorization" on both
upgrade and non-upgrade paths. TestValidDNSLabel replaces the old
TestValidNamespace with the stricter table (accepts 1abc per RFC 1123,
rejects MY-NS, -x, x-, length > 63).

README documents the new validation surface, the headers we strip
before forwarding (Authorization, Origin-on-upgrade, and the
listener-level x-envoy-original-dst-host strip), and the rationale
for each.
Copy link
Copy Markdown
Contributor Author

@mastersingh24 mastersingh24 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aditya-shantanu — thanks, both findings were real. Fixed in 4bc00c6, along with two related gaps the audit surfaced:

  1. X-Sandbox-Pod-IP SSRF (your finding) — added validPodIP using net.ParseIP + the same class checks (IsUnspecified/IsLoopback/IsLinkLocalUnicast/IsLinkLocalMulticast/IsMulticast). Rejects 169.254.169.254, 127.0.0.1, fe80::1, etc. with a 400 + the Python-router error shape.
  2. X-Sandbox-ID DNS-label check (your finding) — added validDNSLabel matching the Python ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + 63-char cap, applied to both ID and Namespace. The old validNamespace was also more permissive than Python's regex (accepted uppercase, leading/trailing hyphens, unbounded length); both fields now share the strict check.
  3. Authorization header strip — audit surfaced this: the Python router drops Authorization right next to Host before forwarding, and we were leaving it. With --authz-mode=tokenreview the router consumes the caller's K8s bearer token; leaking it to the sandbox would let the sandbox impersonate the caller. Done in the Rewrite callback. Integration test TestIntegration_AuthorizationStrippedFromUpstream asserts the backend sees an empty Authorization.

One pragmatic addition: --allow-loopback-pod-ip (default false, the safe behavior). The sidecar deployment shape (sandbox shares the router's Pod, so 127.0.0.1 is the correct dial address) is a legitimate use case, and our integration tests using httptest also need it. Link-local, multicast, and unspecified classes stay rejected regardless of the flag.

Same X-Sandbox-ID DNS-label gap + Authorization strip + ingress-trust point landed on #850 in b23a65b.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. do-not-merge/hold Indicates that a PR should not merge because someone has issued a /hold command. needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. ready-for-review size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

7 participants