Problem
Sandboxed environments — untrusted code execution, CI/CD pipelines, isolated testing — need to control outbound network access from VMs/containers while keeping host-to-guest communication working (orchestrators need to run health checks, collect metrics, or interact with services inside the guest).
Today there is no mechanism to restrict what a guest can reach on the network. A guest can connect to any IP or resolve any domain.
Replaces #599 and #600 — this PR combines the functionality of both with a stronger security posture.
Functional needs
- Full outbound block (
blockAllOutbound): kill all guest-initiated outbound traffic (TCP + UDP), while host-to-guest port forwards keep working for orchestrator use cases (health checks, API calls into the guest, etc.) - Domain allowlist (
outboundAllow): allow outbound connections only to a list of approved domains. This must filter at both layers — DNS resolution (unauthorized domains get NXDOMAIN) and actual IP connections (so hardcoded IPs or SNI spoofing don't bypass the filter) - Gateway exemption: traffic to the gateway address is always allowed (internal services, DNS)
Implementation
blockAllOutbound
TCP and UDP forwarders reject all guest-initiated connections before net.Dial(). Port forwards (host-to-guest) are unaffected since they use a separate path.
outboundAllow
A list of regex patterns matching allowed domains. When configured:
| Traffic | Behavior |
|---|---|
| DNS queries | Checked against allowlist before upstream resolution — blocked domains get NXDOMAIN |
| TCP port 443 | TLS ClientHello peeked → SNI extracted → checked against allowlist → SNI resolved via DNS → resolved IPs compared against actual destination IP |
| TCP non-443 | Blocked (no SNI available to verify) |
| UDP | Only gateway-bound allowed (DNS) |
| Gateway | Always exempt |
Files changed
| File | What |
|---|---|
pkg/types/configuration.go | BlockAllOutbound and OutboundAllow config fields |
pkg/virtualnetwork/services.go | Pass new config to forwarders and DNS server |
pkg/services/forwarder/sni.go | TLS ClientHello parser (SNI + ALPN extraction) |
pkg/services/forwarder/tcp.go | TCP routing, TLS allowlist handler, DNS cross-check |
pkg/services/forwarder/udp.go | UDP routing with allowlist support |
pkg/services/dns/dns.go | DNS allowlist filtering |
+ *_test.go files | Comprehensive tests for all of the above |
Security posture
The SNI parser and allowlist matching are hardened against known bypass techniques:
| Attack | Risk | Mitigation |
|---|---|---|
| IP literal in SNI | RFC 6066 forbids it; CVE-2025-25255 showed Fortinet bypassed via IP-only connections | net.ParseIP() rejects IPv4/IPv6 literals in parseSNIExtension |
| Case mismatch | DNS is case-insensitive (RFC 4343) but regex isn't; EXAMPLE.COM wouldn't match ^example\.com$ | strings.ToLower() at the source — both SNI parser and DNS handler normalize before any matching |
| SNI spoofing | #1 practical bypass — client sends allowed.com in SNI but connects to evil.com's IP | DNS cross-check: resolve SNI hostname, verify at least one IP matches the destination; fail-closed on lookup failure |
| Tunneling detection | Unusual ALPN values (e.g. VLESS+WebSocket using h2) can indicate tunneling | ALPN protocols extracted from ClientHello and logged for observability |
| TLS record fragmentation | Attacker splits ClientHello across multiple TLS records to evade parsers | Parser reassembles fragmented handshake messages before extraction |
| ECH/ESNI | Encrypted Client Hello hides the real SNI; outer SNI can't be trusted | Parser detects ECH (0xfe0d) and legacy ESNI (0xffce) extensions → connection blocked |
Test plan
-
go build ./cmd/gvproxy/ -
go test ./pkg/services/forwarder/ -count=1— 100+ tests pass -
go test ./pkg/services/dns/ -count=1— all pass -
go vet ./pkg/services/forwarder/ ./pkg/services/dns/— clean - Integration test with actual VM
🤖 Generated with Claude Code
Activity
18[APPROVALNOTIFIER] This PR is NOT APPROVED
This pull-request has been approved by: clemlesne Once this PR has been reviewed and has the lgtm label, please assign lstocchi for approval. For more information see the Code Review Process.
The full list of commands accepted by this bot can be found here.
Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment
[APPROVALNOTIFIER] This PR is NOT APPROVED
This pull-request has been approved by: clemlesne Once this PR has been reviewed and has the lgtm label, please assign lstocchi for approval. For more information see the Code Review Process.
The full list of commands accepted by this bot can be found here.
Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment
[APPROVALNOTIFIER] This PR is NOT APPROVED
This pull-request has been approved by: clemlesne Once this PR has been reviewed and has the lgtm label, please assign lstocchi for approval. For more information see the Code Review Process.
The full list of commands accepted by this bot can be found here.
Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment
[APPROVALNOTIFIER] This PR is NOT APPROVED
This pull-request has been approved by: clemlesne Once this PR has been reviewed and has the lgtm label, please assign lstocchi for approval. For more information see the Code Review Process.
The full list of commands accepted by this bot can be found here.
Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment
ECH/GREASE compatibility (04a0b9ce)
Chrome 117+ and Firefox 119+ send ECH GREASE (0xfe0d) on every TLS 1.3 handshake. The proxy was dropping these — breaking HTTPS for modern clients.
Removed the ECH rejection (3 lines in parseClientHello). Per RFC 9849 §8.1.2, the proxy acts on the outer SNI — which the allowlist + DNS cross-validation already enforce. ECH doesn't weaken this: the SNI must still match the regex AND resolve to the destination IP.
Security table correction — ECH row: Outer SNI used per RFC 9849 §8.1.2; allowlist + DNS cross-validation enforced; ECH extensions silently ignored.
ECH/GREASE compatibility (04a0b9ce)
Chrome 117+ and Firefox 119+ send ECH GREASE (0xfe0d) on every TLS 1.3 handshake. The proxy was dropping these — breaking HTTPS for modern clients.
Removed the ECH rejection (3 lines in parseClientHello). Per RFC 9849 §8.1.2, the proxy acts on the outer SNI — which the allowlist + DNS cross-validation already enforce. ECH doesn't weaken this: the SNI must still match the regex AND resolve to the destination IP.
Security table correction — ECH row: Outer SNI used per RFC 9849 §8.1.2; allowlist + DNS cross-validation enforced; ECH extensions silently ignored.
ECH/GREASE compatibility (04a0b9ce)
Chrome 117+ and Firefox 119+ send ECH GREASE (0xfe0d) on every TLS 1.3 handshake. The proxy was dropping these — breaking HTTPS for modern clients.
Removed the ECH rejection (3 lines in parseClientHello). Per RFC 9849 §8.1.2, the proxy acts on the outer SNI — which the allowlist + DNS cross-validation already enforce. ECH doesn't weaken this: the SNI must still match the regex AND resolve to the destination IP.
Security table correction — ECH row: Outer SNI used per RFC 9849 §8.1.2; allowlist + DNS cross-validation enforced; ECH extensions silently ignored.
ECH/GREASE compatibility (04a0b9ce)
Chrome 117+ and Firefox 119+ send ECH GREASE (0xfe0d) on every TLS 1.3 handshake. The proxy was dropping these — breaking HTTPS for modern clients.
Removed the ECH rejection (3 lines in parseClientHello). Per RFC 9849 §8.1.2, the proxy acts on the outer SNI — which the allowlist + DNS cross-validation already enforce. ECH doesn't weaken this: the SNI must still match the regex AND resolve to the destination IP.
Security table correction — ECH row: Outer SNI used per RFC 9849 §8.1.2; allowlist + DNS cross-validation enforced; ECH extensions silently ignored.
See also https://github.com/containers/gvisor-tap-vsock/pull/630 and https://github.com/containers/gvisor-tap-vsock/pull/631 for a similar request.
See also https://github.com/containers/gvisor-tap-vsock/pull/630 and https://github.com/containers/gvisor-tap-vsock/pull/631 for a similar request.
See also https://github.com/containers/gvisor-tap-vsock/pull/630 and https://github.com/containers/gvisor-tap-vsock/pull/631 for a similar request.
PR needs rebase.
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.
PR needs rebase.
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.
[APPROVALNOTIFIER] This PR is NOT APPROVED
This pull-request has been approved by: clemlesne Once this PR has been reviewed and has the lgtm label, please assign lstocchi for approval. For more information see the Code Review Process.
The full list of commands accepted by this bot can be found here.
Approvers can indicate their approval by writing
/approvein a comment Approvers can cancel approval by writing/approve cancelin a comment