Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
89c30ef
feat: add dynamic ingress control for sandboxes
levb Mar 10, 2026
2ba9d60
refactor: deduplicate ingress validation and avoid hot-path allocation
levb Mar 10, 2026
f4059c7
test: add ingress control integration tests
levb Mar 10, 2026
51fec51
test: speed up ingress integration tests
levb Mar 10, 2026
22671e3
test: restore egress CIDR intersection tests
levb Mar 11, 2026
65daa0d
chore: auto-commit generated changes
github-actions[bot] Mar 11, 2026
cf2b029
fix: lint issues from CI (protogetter, revive, modernize, unparam)
levb Mar 11, 2026
18ac69f
fix: consolidate client IP extraction and fix XFF spoofing
levb Mar 11, 2026
248ffcf
Merge branch 'lev-ingress-control' of github.com:e2b-dev/infra into l…
levb Mar 11, 2026
e449b68
test: add integration test for dynamic MaskRequestHost updates
levb Mar 11, 2026
f2df7ed
fix: include IPv6 CIDRs in client IP deny tests
levb Mar 11, 2026
b1240d0
fix: fix MaskRequestHost test echo server startup
levb Mar 11, 2026
2a4e32e
fix: reject allowIn without deny-all and clean up test names
levb Mar 11, 2026
60351fc
Merge branch 'lev-allow-deny-dynamic' into lev-ingress-control
levb Mar 11, 2026
b97f951
fix(orchestrator): use context.WithoutCancel for egress rollback
levb Mar 11, 2026
b609b47
refactor(orchestrator): restore ApplyAllOrNone for update rollbacks
levb Mar 11, 2026
e1b618d
test: trim redundant unit tests, add combined egress+ingress integrat…
levb Mar 11, 2026
2b4e58a
chore: auto-commit generated changes
github-actions[bot] Mar 11, 2026
68f5571
fix: close response body from WaitForStatus to satisfy bodyclose lint
levb Mar 11, 2026
c38bf3b
Merge branch 'lev-ingress-control' of github.com:e2b-dev/infra into l…
levb Mar 11, 2026
e1a910c
fix(client-proxy): strip X-E2B-Client-IP before extracting client IP
levb Mar 11, 2026
71ff3e0
Merge remote-tracking branch 'e2b/lev-allow-deny-dynamic' into lev-in…
levb Mar 12, 2026
89c3dd2
Fix data race on network egress/ingress config access
levb Mar 12, 2026
4e8a061
Merge branch 'lev-allow-deny-dynamic' into lev-ingress-control
levb Mar 12, 2026
645148b
Fix spoofed-IP ingress tests to use XFF instead of X-E2B-Client-IP
levb Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
342 changes: 186 additions & 156 deletions packages/api/internal/api/api.gen.go

Large diffs are not rendered by default.

69 changes: 15 additions & 54 deletions packages/api/internal/handlers/sandbox_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"net"
"net/http"
"path/filepath"
"slices"
"strings"
"time"

Expand All @@ -32,7 +31,6 @@
"github.com/e2b-dev/infra/packages/shared/pkg/grpc/orchestrator"
"github.com/e2b-dev/infra/packages/shared/pkg/id"
sbxlogger "github.com/e2b-dev/infra/packages/shared/pkg/logger/sandbox"
sandbox_network "github.com/e2b-dev/infra/packages/shared/pkg/sandbox-network"
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
sharedUtils "github.com/e2b-dev/infra/packages/shared/pkg/utils"
)
Expand Down Expand Up @@ -165,8 +163,12 @@

network = &types.SandboxNetworkConfig{
Ingress: &types.SandboxNetworkIngressConfig{
AllowPublicAccess: n.AllowPublicTraffic,
MaskRequestHost: n.MaskRequestHost,
AllowPublicAccess: n.AllowPublicTraffic,
MaskRequestHost: n.MaskRequestHost,
AllowedPorts: intsToUint32s(n.AllowPorts),
DeniedPorts: intsToUint32s(n.DenyPorts),
AllowedClientCIDRs: sharedUtils.DerefOrDefault(n.AllowIn, nil),
DeniedClientCIDRs: sharedUtils.DerefOrDefault(n.DenyIn, nil),
},
Egress: &types.SandboxNetworkEgressConfig{
AllowedAddresses: sharedUtils.DerefOrDefault(n.AllowOut, nil),
Expand Down Expand Up @@ -504,56 +506,15 @@
denyOut := sharedUtils.DerefOrDefault(network.DenyOut, nil)
allowOut := sharedUtils.DerefOrDefault(network.AllowOut, nil)

return validateEgressRules(allowOut, denyOut)
}

// validateEgressRules validates egress allow/deny rules:
// - denyOut entries must be valid IPs or CIDRs (not domains)
// - allowOut entries must be valid IPs, CIDRs, or domain names
// - when allowOut contains domains, denyOut must include 0.0.0.0/0
func validateEgressRules(allowOut, denyOut []string) *api.APIError {
for _, cidr := range denyOut {
if !sandbox_network.IsIPOrCIDR(cidr) {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid denied CIDR %s", cidr),
ClientMsg: fmt.Sprintf("invalid denied CIDR %s", cidr),
}
}
if apiErr := validateEgressRules(allowOut, denyOut); apiErr != nil {
return apiErr
}

if len(allowOut) > 0 {
_, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowOut)

for _, domain := range allowedDomains {
// Strip wildcard prefix for IDNA validation (*.example.com → example.com).
// The "*" label is not a valid IDNA label, but we support it as a wildcard.
validateDomain := domain
if strings.HasPrefix(domain, "*.") {
validateDomain = domain[2:]
}

if validateDomain != "*" {
if _, err := idna.Lookup.ToASCII(validateDomain); err != nil {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid allowed domain %q: %w", domain, err),
ClientMsg: fmt.Sprintf("invalid allowed domain: %s", domain),
}
}
}
}

hasBlockAll := slices.Contains(denyOut, sandbox_network.AllInternetTrafficCIDR)

if len(allowedDomains) > 0 && !hasBlockAll {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("allow out contains domains but deny out is missing 0.0.0.0/0 (ALL_TRAFFIC)"),
ClientMsg: ErrMsgDomainsRequireBlockAll,
}
}
}

return nil
return validateIngressRules(&types.SandboxNetworkIngressConfig{
AllowedPorts: intsToUint32s(network.AllowPorts),
DeniedPorts: intsToUint32s(network.DenyPorts),
AllowedClientCIDRs: sharedUtils.DerefOrDefault(network.AllowIn, nil),
DeniedClientCIDRs: sharedUtils.DerefOrDefault(network.DenyIn, nil),
})
}

Check failure on line 520 in packages/api/internal/handlers/sandbox_create.go

View workflow job for this annotation

GitHub Actions / lint / golangci-lint (/home/runner/work/infra/infra/packages/api)

File is not properly formatted (gci)
73 changes: 45 additions & 28 deletions packages/api/internal/handlers/sandbox_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,71 +175,88 @@ func TestValidateNetworkConfig(t *testing.T) {
},
wantErr: false,
},
// Ingress port validation tests
{
name: "allow_out with IP and deny_out block-all is valid",
name: "valid allowPorts",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"8.8.8.8"},
DenyOut: &[]string{sandbox_network.AllInternetTrafficCIDR},
AllowPorts: &[]int{80, 443, 8080},
},
wantErr: false,
},
// CIDR intersection validation tests
{
name: "allow_out CIDR not covered by deny_out CIDR is valid (no intersection check)",
name: "allowPorts with port 0 is invalid",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"10.0.0.0/8"},
DenyOut: &[]string{"192.168.0.0/16"}, // No intersection, but still valid
AllowPorts: &[]int{0},
},
wantErr: false,
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid allowPorts port 0: must be between 1 and 65535",
},
{
name: "allow_out CIDR covered by intersecting deny_out CIDR is valid",
name: "allowPorts with port > 65535 is invalid",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"10.1.0.0/16"},
DenyOut: &[]string{"10.0.0.0/8"}, // Deny covers allow
AllowPorts: &[]int{70000},
},
wantErr: false,
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid allowPorts port 70000: must be between 1 and 65535",
},
{
name: "allow_out CIDR covers deny_out CIDR is valid (intersection exists)",
name: "valid denyPorts",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"10.0.0.0/8"},
DenyOut: &[]string{"10.1.0.0/16"}, // Allow covers deny - still valid intersection
DenyPorts: &[]int{22, 3306},
},
wantErr: false,
},
{
name: "allow_out IP covered by deny_out CIDR is valid",
name: "denyPorts with port 0 is invalid",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"10.1.2.3"},
DenyOut: &[]string{"10.0.0.0/8"},
DenyPorts: &[]int{0},
},
wantErr: false,
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid denyPorts port 0: must be between 1 and 65535",
},
// Ingress CIDR validation tests
{
name: "allow_out IP not covered by deny_out CIDR is valid (no intersection check)",
name: "valid allowIn CIDR",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"8.8.8.8"},
DenyOut: &[]string{"10.0.0.0/8"},
AllowIn: &[]string{"10.0.0.0/8"},
},
wantErr: false,
},
{
name: "multiple allow_out CIDRs partial deny_out coverage is valid (no intersection check)",
name: "valid allowIn bare IP",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"10.0.0.0/8", "192.168.0.0/16"},
DenyOut: &[]string{"10.0.0.0/8"}, // Only covers first, but still valid
AllowIn: &[]string{"1.2.3.4"},
},
wantErr: false,
},
{
name: "multiple allow_out CIDRs covered by multiple deny_out CIDRs is valid",
name: "invalid allowIn entry",
network: &api.SandboxNetworkConfig{
AllowIn: &[]string{"not-a-cidr"},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid allowIn CIDR not-a-cidr",
},
{
name: "valid denyIn CIDR",
network: &api.SandboxNetworkConfig{
AllowOut: &[]string{"10.0.0.0/8", "192.168.0.0/16"},
DenyOut: &[]string{"10.0.0.0/8", "192.168.0.0/16"},
DenyIn: &[]string{"192.168.0.0/16"},
},
wantErr: false,
},
{
name: "invalid denyIn entry",
network: &api.SandboxNetworkConfig{
DenyIn: &[]string{"bad"},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid denyIn CIDR bad",
},
// Mixed domain and CIDR tests
{
name: "allow_out with domain and CIDR without deny_out block-all is invalid",
Expand Down
139 changes: 131 additions & 8 deletions packages/api/internal/handlers/sandbox_network_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ package handlers
import (
"fmt"
"net/http"
"slices"
"strings"

"github.com/gin-gonic/gin"
"golang.org/x/net/idna"

"github.com/e2b-dev/infra/packages/api/internal/api"
"github.com/e2b-dev/infra/packages/api/internal/utils"
"github.com/e2b-dev/infra/packages/auth/pkg/auth"
"github.com/e2b-dev/infra/packages/db/pkg/types"
sandbox_network "github.com/e2b-dev/infra/packages/shared/pkg/sandbox-network"
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
sutils "github.com/e2b-dev/infra/packages/shared/pkg/utils"
)

func (a *APIStore) PutSandboxesSandboxIDNetwork(
Expand All @@ -36,23 +42,32 @@ func (a *APIStore) PutSandboxesSandboxIDNetwork(
return
}

var allowedEntries []string
if body.AllowOut != nil {
allowedEntries = *body.AllowOut
egressUpdate := &types.SandboxNetworkEgressConfig{
AllowedAddresses: sutils.DerefOrDefault(body.AllowOut, nil),
DeniedAddresses: sutils.DerefOrDefault(body.DenyOut, nil),
}

var deniedEntries []string
if body.DenyOut != nil {
deniedEntries = *body.DenyOut
ingressUpdate := &types.SandboxNetworkIngressConfig{
MaskRequestHost: body.MaskRequestHost,
AllowedPorts: intsToUint32s(body.AllowPorts),
DeniedPorts: intsToUint32s(body.DenyPorts),
AllowedClientCIDRs: sutils.DerefOrDefault(body.AllowIn, nil),
DeniedClientCIDRs: sutils.DerefOrDefault(body.DenyIn, nil),
}

if apiErr := validateEgressRules(allowedEntries, deniedEntries); apiErr != nil {
if apiErr := validateEgressRules(egressUpdate.AllowedAddresses, egressUpdate.DeniedAddresses); apiErr != nil {
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)

return
}

if apiErr := a.orchestrator.UpdateSandboxNetworkConfig(ctx, team.ID, sandboxID, allowedEntries, deniedEntries); apiErr != nil {
if apiErr := validateIngressRules(ingressUpdate); apiErr != nil {
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)

return
}

if apiErr := a.orchestrator.UpdateSandboxNetworkConfig(ctx, team.ID, sandboxID, egressUpdate, ingressUpdate); apiErr != nil {
telemetry.ReportErrorByCode(ctx, apiErr.Code, "error updating sandbox network config", apiErr.Err)
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)

Expand All @@ -61,3 +76,111 @@ func (a *APIStore) PutSandboxesSandboxIDNetwork(

c.Status(http.StatusNoContent)
}

func intsToUint32s(ints *[]int) []uint32 {
if ints == nil {
return nil
}

result := make([]uint32, len(*ints))
for i, v := range *ints {
result[i] = uint32(v)
}

return result
}

// validateEgressRules validates egress allow/deny rules:
// - denyOut entries must be valid IPs or CIDRs (not domains)
// - allowOut entries must be valid IPs, CIDRs, or domain names
// - when allowOut contains domains, denyOut must include 0.0.0.0/0
func validateEgressRules(allowOut, denyOut []string) *api.APIError {
for _, cidr := range denyOut {
if !sandbox_network.IsIPOrCIDR(cidr) {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid denied CIDR %s", cidr),
ClientMsg: fmt.Sprintf("invalid denied CIDR %s", cidr),
}
}
}

if len(allowOut) > 0 {
_, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowOut)

for _, domain := range allowedDomains {
// Strip wildcard prefix for IDNA validation (*.example.com -> example.com).
// The "*" label is not a valid IDNA label, but we support it as a wildcard.
validateDomain := domain
if strings.HasPrefix(domain, "*.") {
validateDomain = domain[2:]
}

if validateDomain != "*" {
if _, err := idna.Lookup.ToASCII(validateDomain); err != nil {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid allowed domain %q: %w", domain, err),
ClientMsg: fmt.Sprintf("invalid allowed domain: %s", domain),
}
}
}
}

hasBlockAll := slices.Contains(denyOut, sandbox_network.AllInternetTrafficCIDR)

if len(allowedDomains) > 0 && !hasBlockAll {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("allow out contains domains but deny out is missing 0.0.0.0/0 (ALL_TRAFFIC)"),
ClientMsg: ErrMsgDomainsRequireBlockAll,
}
}
}

return nil
}

func validateIngressRules(ingress *types.SandboxNetworkIngressConfig) *api.APIError {
if apiErr := validatePortList(ingress.AllowedPorts, "allowPorts"); apiErr != nil {
return apiErr
}

if apiErr := validatePortList(ingress.DeniedPorts, "denyPorts"); apiErr != nil {
return apiErr
}

if apiErr := validateCIDRList(ingress.AllowedClientCIDRs, "allowIn"); apiErr != nil {
return apiErr
}

return validateCIDRList(ingress.DeniedClientCIDRs, "denyIn")
}

func validatePortList(ports []uint32, fieldName string) *api.APIError {
for _, p := range ports {
if p == 0 || p > 65535 {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid %s port %d", fieldName, p),
ClientMsg: fmt.Sprintf("invalid %s port %d: must be between 1 and 65535", fieldName, p),
}
}
}

return nil
}

func validateCIDRList(cidrs []string, fieldName string) *api.APIError {
for _, cidr := range cidrs {
if !sandbox_network.IsIPOrCIDR(cidr) {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("invalid %s CIDR %s", fieldName, cidr),
ClientMsg: fmt.Sprintf("invalid %s CIDR %s", fieldName, cidr),
}
}
}

return nil
}
4 changes: 4 additions & 0 deletions packages/api/internal/orchestrator/create_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func buildNetworkConfig(network *types.SandboxNetworkConfig, allowInternetAccess

if network != nil && network.Ingress != nil {
orchNetwork.Ingress.MaskRequestHost = network.Ingress.MaskRequestHost
orchNetwork.Ingress.AllowedPorts = network.Ingress.AllowedPorts
orchNetwork.Ingress.DeniedPorts = network.Ingress.DeniedPorts
orchNetwork.Ingress.AllowedClientCidrs = network.Ingress.AllowedClientCIDRs
orchNetwork.Ingress.DeniedClientCidrs = network.Ingress.DeniedClientCIDRs
}

// Handle the case where internet access is explicitly disabled
Expand Down
Loading
Loading