Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.

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

Expand All @@ -32,7 +31,6 @@ import (
"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 @@ func (a *APIStore) PostSandboxes(c *gin.Context) {

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,36 +506,14 @@ func validateNetworkConfig(network *api.SandboxNetworkConfig) *api.APIError {
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 len(allowOut) > 0 {
_, allowedDomains := sandbox_network.ParseAddressesAndDomains(allowOut)
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,
}
}
if apiErr := validateEgressRules(allowOut, denyOut); apiErr != nil {
return apiErr
}

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),
})
}
103 changes: 103 additions & 0 deletions packages/api/internal/handlers/sandbox_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,109 @@ func TestValidateNetworkConfig(t *testing.T) {
},
wantErr: false,
},
// Ingress port validation tests
{
name: "valid allowPorts",
network: &api.SandboxNetworkConfig{
AllowPorts: &[]int{80, 443, 8080},
},
wantErr: false,
},
{
name: "allowPorts with port 0 is invalid",
network: &api.SandboxNetworkConfig{
AllowPorts: &[]int{0},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid allowPorts port 0: must be between 1 and 65535",
},
{
name: "allowPorts with port > 65535 is invalid",
network: &api.SandboxNetworkConfig{
AllowPorts: &[]int{70000},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid allowPorts port 70000: must be between 1 and 65535",
},
{
name: "valid denyPorts",
network: &api.SandboxNetworkConfig{
DenyPorts: &[]int{22, 3306},
},
wantErr: false,
},
{
name: "denyPorts with port 0 is invalid",
network: &api.SandboxNetworkConfig{
DenyPorts: &[]int{0},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "invalid denyPorts port 0: must be between 1 and 65535",
},
// Ingress CIDR validation tests
{
name: "valid allowIn CIDR with deny-all",
network: &api.SandboxNetworkConfig{
AllowIn: &[]string{"10.0.0.0/8"},
DenyIn: &[]string{"0.0.0.0/0"},
},
wantErr: false,
},
{
name: "valid allowIn bare IP with deny-all",
network: &api.SandboxNetworkConfig{
AllowIn: &[]string{"1.2.3.4"},
DenyIn: &[]string{"0.0.0.0/0"},
},
wantErr: false,
},
{
name: "allowIn without deny-all is rejected",
network: &api.SandboxNetworkConfig{
AllowIn: &[]string{"10.0.0.0/8"},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "When specifying allowed CIDRs in allowIn, you must include '0.0.0.0/0' in denyIn to block all other traffic.",
},
{
name: "allowIn with partial denyIn is rejected",
network: &api.SandboxNetworkConfig{
AllowIn: &[]string{"10.0.0.0/8"},
DenyIn: &[]string{"192.168.0.0/16"},
},
wantErr: true,
wantCode: http.StatusBadRequest,
wantErrMsg: "When specifying allowed CIDRs in allowIn, you must include '0.0.0.0/0' in denyIn to block all other traffic.",
},
{
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{
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
153 changes: 145 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,125 @@ 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
}

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

// Consistent with egress: allowIn without deny-all is a no-op (default is allow-all),
// so require 0.0.0.0/0 in denyIn to prevent a silent misconfiguration.
if len(ingress.AllowedClientCIDRs) > 0 && !slices.Contains(ingress.DeniedClientCIDRs, sandbox_network.AllInternetTrafficCIDR) {
return &api.APIError{
Code: http.StatusBadRequest,
Err: fmt.Errorf("allowIn is set but denyIn is missing 0.0.0.0/0 (ALL_TRAFFIC)"),
ClientMsg: "When specifying allowed CIDRs in allowIn, you must include '0.0.0.0/0' in denyIn to block all other traffic.",
}
}

return nil
}

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