Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1,284 changes: 645 additions & 639 deletions packages/api/internal/api/api.gen.go

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions packages/api/internal/handlers/sandbox_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
Expand Down Expand Up @@ -151,6 +152,41 @@ func newSandboxFromV2(body api.NewSandboxV2) api.NewSandbox {
}
}

// validateEnvVarsSize rejects a create whose user env map, once JSON-serialised,
// would not fit the budget the public API reserves for env vars in envd's /init
// body (consts.MaxSandboxEnvVarsBytes). The orchestrator serialises the whole env
// map into POST /init, which envd caps at consts.EnvdMaxInitBodyBytes and whose
// 413 the orchestrator treats as a fatal create/resume. Bounding env here, at
// admission, keeps that failure from reaching a sandbox the API already accepted;
// the schema in openapi.yml does not cap env, so this is the only place it is held.
func validateEnvVarsSize(envVars api.EnvVars) *api.APIError {
if len(envVars) == 0 {
return nil
}

encoded, err := json.Marshal(envVars)
if err != nil {
return &api.APIError{
Code: http.StatusBadRequest,
ClientMsg: "Environment variables could not be encoded",
Err: fmt.Errorf("marshal env vars for size check: %w", err),
}
}

if int64(len(encoded)) > consts.MaxSandboxEnvVarsBytes {
return &api.APIError{
Code: http.StatusBadRequest,
ClientMsg: fmt.Sprintf(
"Environment variables are too large: %d bytes serialised, limit is %d bytes",
len(encoded), consts.MaxSandboxEnvVarsBytes,
),
Err: fmt.Errorf("env vars exceed admission limit: %d > %d", len(encoded), consts.MaxSandboxEnvVarsBytes),
}
}

return nil
}

// createSandbox runs the shared create flow; defaultTimeout applies when the body omits timeout.
func (a *APIStore) createSandbox(c *gin.Context, body api.NewSandbox, defaultTimeout time.Duration) (*api.Sandbox, *api.APIError) {
ctx := c.Request.Context()
Expand Down Expand Up @@ -242,6 +278,11 @@ func (a *APIStore) createSandbox(c *gin.Context, body api.NewSandbox, defaultTim
// timeout auto-pause takes a filesystem-only snapshot (cold-boots on resume).
autoPauseFilesystemOnly := !sharedUtils.DerefOrDefault(body.AutoPauseMemory, true)
envVars := sharedUtils.DerefOrDefault(body.EnvVars, nil)
if apiErr := validateEnvVarsSize(envVars); apiErr != nil {
apiErr.Retryable = apierrors.NoRetry

return nil, apiErr
}
mcp := sharedUtils.DerefOrDefault(body.Mcp, nil)
metadata := sharedUtils.DerefOrDefault(body.Metadata, nil)
apiVolumeMounts := sharedUtils.DerefOrDefault(body.VolumeMounts, nil)
Expand Down
53 changes: 53 additions & 0 deletions packages/api/internal/handlers/sandbox_create_envsize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package handlers

import (
"net/http"
"strings"
"testing"

"github.com/e2b-dev/infra/packages/api/internal/api"
"github.com/e2b-dev/infra/packages/shared/pkg/consts"
)

func TestValidateEnvVarsSize(t *testing.T) {
t.Run("nil map is admitted", func(t *testing.T) {
if err := validateEnvVarsSize(nil); err != nil {
t.Fatalf("nil env map rejected: %v", err)
}
})

t.Run("empty map is admitted", func(t *testing.T) {
if err := validateEnvVarsSize(api.EnvVars{}); err != nil {
t.Fatalf("empty env map rejected: %v", err)
}
})

t.Run("ordinary map is admitted", func(t *testing.T) {
env := api.EnvVars{"FOO": "bar", "PATH": "/usr/bin:/bin", "TOKEN": strings.Repeat("x", 4096)}
if err := validateEnvVarsSize(env); err != nil {
t.Fatalf("ordinary env map rejected: %v", err)
}
})

t.Run("map at the budget is admitted", func(t *testing.T) {
// Value sized so the JSON encoding lands just under the cap:
// {"K":"<value>"} framing is 8 bytes.
val := strings.Repeat("a", int(consts.MaxSandboxEnvVarsBytes)-8)
env := api.EnvVars{"K": val}
if err := validateEnvVarsSize(env); err != nil {
t.Fatalf("env map at budget rejected: %v", err)
}
})

t.Run("map over the budget is rejected with 400", func(t *testing.T) {
val := strings.Repeat("a", int(consts.MaxSandboxEnvVarsBytes))
env := api.EnvVars{"K": val}
err := validateEnvVarsSize(env)
if err == nil {
t.Fatal("oversized env map admitted")
}
if err.Code != http.StatusBadRequest {
t.Fatalf("got code %d, want %d", err.Code, http.StatusBadRequest)
}
})
}
96 changes: 96 additions & 0 deletions packages/envd/internal/api/bodylimit/bodylimit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Package bodylimit caps the request-body size per path at the server edge, so
// a process inside the VM cannot OOM-kill envd -- the sandbox control plane --
// by streaming an unbounded body into a handler that reads it into memory.
//
// It is defense-in-depth, layered UNDER the cgroup memory protection that is
// the primary guarantee against an envd OOM kill (envd.service MemoryMin /
// system.slice drop-in). The cap covers the one allocation path a handler
// controls; memory.min covers every path, including the ones a cap cannot.
//
// The policy is a default cap on every path, with an explicit exempt list for
// the endpoints that stream their body instead of buffering it. Default-deny
// means a handler added later is capped without the author remembering to; the
// single-endpoint fix it replaces protected only the one path it touched.
package bodylimit

import (
"net/http"
"strings"
"time"

"github.com/e2b-dev/infra/packages/shared/pkg/consts"
)

// DefaultMaxBytes caps any endpoint that reads its body into memory. The largest
// legitimate /init payload (env vars plus a CA bundle) is a few KiB; 1 MiB is far
// above it and cannot reject orchestrator traffic. It is defined once in the shared
// consts package because the public API derives its env-var admission bound from the
// same ceiling (consts.MaxSandboxEnvVarsBytes), so an admitted sandbox cannot have
// its /init body rejected here; the two must not drift.
const DefaultMaxBytes int64 = consts.EnvdMaxInitBodyBytes // 1 MiB

// DefaultBodyTimeout bounds how long a non-exempt (buffered) path may take to
// read its whole body. The server keeps ReadTimeout at 0 so the long-lived
// streaming paths are not cut; that leaves a slow-body slowloris on the capped
// paths -- an attacker finishes the headers fast (so ReadHeaderTimeout does not
// fire) then dribbles a sub-cap body one byte at a time, holding the connection
// and its goroutine open indefinitely (MaxBytesReader caps size, not time).
// A per-request read deadline on just the non-exempt paths closes that surface
// without touching the exempt streaming/connect paths, which legitimately read
// their body for a long time and are never wrapped here. 30s is far above the
// time to read a few-KiB /init over any real link.
const DefaultBodyTimeout = 30 * time.Second

// exemptPrefixes stream their body to disk or parse it part by part, so they never
// hold the whole body in heap and must NOT be size-capped by MaxBytesReader:
//
// - POST /files : multipart.Part streaming / raw octet-stream to disk (upload.go)
// - POST /upgrade : io.Copy(f, r.Body) to a fixed path (main.go)
//
// The connect RPC services carry their own framing and their own read cap
// (connect.WithReadMaxBytes, set where each handler is mounted); wrapping their
// body in a MaxBytesReader here would corrupt gRPC/connect framing, so their
// path prefixes are exempt too.
var exemptPrefixes = []string{
"/files",
"/upgrade",
"/filesystem.", // connect: /filesystem.Filesystem/*
"/process.", // connect: /process.Process/*
}

func exempt(path string) bool {
for _, p := range exemptPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}

return false
}

// Wrap caps the request body on every non-exempt path. MaxBytesReader also makes
// the server close the connection after the response instead of draining the
// oversized remainder, and surfaces *http.MaxBytesError on the read so a handler
// can answer 413 Request Entity Too Large (see PostInit).
//
// On the same non-exempt paths it also sets a body read deadline
// (DefaultBodyTimeout): the size cap alone does not bound a slow body, and the
// server's ReadTimeout stays 0 for the streaming paths, so without a deadline a
// capped path is still open to a slow-body slowloris. The deadline is set only
// here, on the buffered paths; the exempt streaming/connect paths are never
// wrapped and keep their unbounded read. SetReadDeadline is best-effort: a
// ResponseWriter that does not support it (ErrNotSupported) just leaves the
// behaviour as before the deadline, never failing the request.
func Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && !exempt(r.URL.Path) {
r.Body = http.MaxBytesReader(w, r.Body, DefaultMaxBytes)
// Bound the time to read the (now size-capped) body. Ignore
// ErrNotSupported so a non-deadline-capable writer degrades to the
// prior size-only behaviour instead of erroring.
_ = http.NewResponseController(w).SetReadDeadline(time.Now().Add(DefaultBodyTimeout))
}

next.ServeHTTP(w, r)
})
}
115 changes: 115 additions & 0 deletions packages/envd/internal/api/bodylimit/bodylimit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package bodylimit

import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

// readAllHandler reads the whole body (like PostInit) and maps a MaxBytesError to
// 413, any other read error to 400, and success to 200.
func readAllHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := io.ReadAll(r.Body); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
})
}

func do(t *testing.T, path string, size int) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(strings.Repeat("a", size)))
rec := httptest.NewRecorder()
Wrap(readAllHandler()).ServeHTTP(rec, req)
return rec.Code
}

func TestWrap_RejectsOversizedBodyOnCappedPath(t *testing.T) {
if code := do(t, "/init", int(DefaultMaxBytes)+1); code != http.StatusRequestEntityTooLarge {
t.Fatalf("one byte over the cap: got %d, want 413", code)
}
}

func TestWrap_AcceptsBodyAtCap(t *testing.T) {
if code := do(t, "/init", int(DefaultMaxBytes)); code != http.StatusOK {
t.Fatalf("body at the cap: got %d, want 200", code)
}
}

func TestWrap_ExemptPathNotCapped(t *testing.T) {
for _, path := range []string{"/files", "/upgrade", "/filesystem.Filesystem/Stat", "/process.Process/Start"} {
if code := do(t, path, int(DefaultMaxBytes)+1024); code != http.StatusOK {
t.Fatalf("exempt path %q should not be capped: got %d, want 200", path, code)
}
}
}

func TestExempt(t *testing.T) {
cases := map[string]bool{
"/init": false,
"/metrics": false,
"/health": false,
"/files": true,
"/files/anything": true,
"/upgrade": true,
"/filesystem.Filesystem/Stat": true,
"/process.Process/Start": true,
}
for path, want := range cases {
if got := exempt(path); got != want {
t.Errorf("exempt(%q) = %v, want %v", path, got, want)
}
}
}

// deadlineRecorder is an httptest.ResponseRecorder that also satisfies the
// SetReadDeadline probe http.NewResponseController uses, so Wrap's best-effort
// deadline call is observable instead of hitting ErrNotSupported.
type deadlineRecorder struct {
*httptest.ResponseRecorder
deadlineSet bool
}

func (d *deadlineRecorder) SetReadDeadline(time.Time) error {
d.deadlineSet = true

return nil
}

func didSetReadDeadline(t *testing.T, path string) bool {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("a"))
rec := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
Wrap(readAllHandler()).ServeHTTP(rec, req)

return rec.deadlineSet
}

// A non-exempt (buffered) path gets a body read deadline, closing the slow-body
// slowloris that the size cap alone leaves open while ReadTimeout stays 0.
func TestWrap_SetsBodyDeadlineOnCappedPath(t *testing.T) {
if !didSetReadDeadline(t, "/init") {
t.Fatal("a capped path must get a body read deadline")
}
}

// Exempt streaming/connect paths must NOT get the deadline: they legitimately
// read their body for a long time and are never wrapped.
func TestWrap_NoBodyDeadlineOnExemptPath(t *testing.T) {
for _, path := range []string{"/files", "/upgrade", "/filesystem.Filesystem/Stat", "/process.Process/Start"} {
if didSetReadDeadline(t, path) {
t.Fatalf("exempt path %q must not get a body read deadline", path)
}
}
}
15 changes: 14 additions & 1 deletion packages/envd/internal/api/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,24 @@ func (a *API) PostInit(w http.ResponseWriter, r *http.Request) {
freezeGen := a.workloadFreezer.FreezeGeneration(ctx)

if r.Body != nil {
// Read raw body so we can wipe it after parsing
// Read raw body so we can wipe it after parsing. The body is capped at the
// server edge by the bodylimit middleware (POST /init is not exempt), so this
// read is bounded even though /init is auth-excluded and the server sets no
// ReadTimeout -- without that cap a guest process could stream an unbounded
// body here and OOM envd, the control plane, with no credentials.
body, err := io.ReadAll(r.Body)
// Ensure body is wiped after we're done
defer memguard.WipeBytes(body)
if err != nil {
// An oversized body trips the cap: answer 413 and attempt no unfreeze.
// Other read errors keep the existing 400 path.
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
logger.Error().Int64("limit_bytes", maxErr.Limit).Msg("request body exceeds the size cap")
w.WriteHeader(http.StatusRequestEntityTooLarge)

return
}
logger.Error().Msgf("Failed to read request body: %v", err)
w.WriteHeader(http.StatusBadRequest)

Expand Down
13 changes: 12 additions & 1 deletion packages/envd/internal/services/filesystem/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ type Service struct {
watchersMu *sync.Mutex
}

// maxConnectReadBytes caps one connect request message. Bulk file transfers use the
// streaming /files endpoint, not these RPCs, so this bounds metadata calls and WriteFile
// chunks well above any legitimate message while keeping a guest from OOMing envd through
// the RPC surface. Defense-in-depth under the cgroup memory protection.
const maxConnectReadBytes = 4 << 20 // 4 MiB

func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults) Service {
service := Service{
logger: l,
Expand All @@ -41,7 +47,12 @@ func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults)
legacy.Convert(),
)

path, handler := spec.NewFilesystemHandler(service, interceptors)
// Cap a single connect request message so a guest process cannot OOM envd
// (the control plane) through the RPC surface the way an uncapped /init body
// could. The filesystem RPCs carry metadata and bounded WriteFile chunks, not
// bulk transfers (those use the streaming /files endpoint), so maxConnectReadBytes
// is well above any legitimate message. Defense-in-depth under cgroup memory.min.
path, handler := spec.NewFilesystemHandler(service, interceptors, connect.WithReadMaxBytes(maxConnectReadBytes))

server.Mount(path, handler)

Expand Down
Loading