Skip to content

Commit 3a4cfa4

Browse files
authored
Feature/sandboxed workers (#21)
* Added Linux-only nsjail-based sandboxing for worker processes, including CLI support, configuration, and testing. * Added validation for `Content-Length` in `headerPrefixPipe` and tests for oversized and negative values * Enhanced `build.yml` to compile and install nsjail from source instead of using system package. * Switched nsjail mode from "once" to "exec" for direct command execution with inherited stdio. * Replaced `--time_limit` with `--rlimit_cpu` in nsjail arguments to ensure compatibility in containers without cgroupv2. * Updated sandbox test to replace `--time_limit` with `--rlimit_cpu` and adjusted workflow to run integration tests with elevated permissions.
1 parent 6940711 commit 3a4cfa4

15 files changed

Lines changed: 874 additions & 5 deletions

.github/workflows/build.yml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,41 @@ jobs:
8383
# name: Go-results
8484
# path: TestResults.json
8585

86+
test-sandbox:
87+
name: Sandbox Integration Tests
88+
runs-on: ubuntu-latest
89+
steps:
90+
- name: Checkout
91+
uses: actions/checkout@v4
92+
with:
93+
fetch-depth: 0
94+
95+
- name: Setup Go
96+
uses: actions/setup-go@v5
97+
with:
98+
go-version-file: ./go.mod
99+
100+
- name: Install Dependencies
101+
run: go mod download
102+
103+
- name: Install nsjail
104+
run: |
105+
sudo apt-get update
106+
sudo apt-get install -y \
107+
autoconf bison flex g++ git \
108+
libprotobuf-dev libnl-route-3-dev \
109+
libtool pkg-config protobuf-compiler
110+
git clone --depth=1 https://github.com/google/nsjail.git /tmp/nsjail
111+
make -C /tmp/nsjail -j$(nproc)
112+
sudo install -m 0755 /tmp/nsjail/nsjail /usr/sbin/nsjail
113+
114+
- name: Run sandbox integration tests
115+
run: sudo -E go test -v -run 'TestSandboxedWorker' ./internal/execution/worker/...
116+
86117
build_docker:
87118
name: Build Docker Image
88119
runs-on: ubuntu-latest
89-
needs: [test, build]
120+
needs: [test, test-sandbox, build]
90121
concurrency:
91122
group: ${{ github.ref }}
92123
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref_name != github.event.repository.default_branch }}

Dockerfile

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,64 @@ ARG COMMIT
1818
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH VERSION=$VERSION COMMIT=$COMMIT \
1919
make build
2020

21-
FROM scratch
21+
# Build nsjail from source. This stage is Linux/amd64 only; nsjail is a
22+
# Linux kernel feature and does not cross-compile for other OS targets.
23+
FROM ubuntu:24.04 AS nsjail-builder
2224

23-
# add binary to empty scratch image
25+
RUN apt-get update && apt-get install -y --no-install-recommends \
26+
autoconf \
27+
bison \
28+
ca-certificates \
29+
flex \
30+
gcc \
31+
g++ \
32+
git \
33+
libcap-dev \
34+
libnl-route-3-dev \
35+
libprotobuf-dev \
36+
libtool \
37+
make \
38+
pkg-config \
39+
protobuf-compiler \
40+
&& rm -rf /var/lib/apt/lists/*
41+
42+
RUN git clone --depth=1 https://github.com/google/nsjail.git /nsjail-src
43+
WORKDIR /nsjail-src
44+
RUN make -j$(nproc)
45+
46+
# Test-only stage: golang base image (Debian bookworm) + nsjail built from source.
47+
# Go is pre-installed; all nsjail build deps are in Debian main — no universe needed.
48+
# Used by `make test-sandbox`; not referenced by the production image.
49+
FROM golang:1.24 AS test-sandbox
50+
RUN apt-get update && apt-get install -y --no-install-recommends \
51+
autoconf \
52+
bison \
53+
ca-certificates \
54+
flex \
55+
libcap-dev \
56+
libnl-route-3-dev \
57+
libprotobuf-dev \
58+
libtool \
59+
pkg-config \
60+
protobuf-compiler \
61+
&& rm -rf /var/lib/apt/lists/*
62+
RUN git clone --depth=1 https://github.com/google/nsjail.git /nsjail-src && \
63+
make -C /nsjail-src -j$(nproc) && \
64+
cp /nsjail-src/nsjail /usr/sbin/nsjail
65+
66+
# Runtime image. Cannot use scratch because nsjail requires shared libraries
67+
# (libcap, libprotobuf, libnl). Image size grows from ~8 MB to ~90-120 MB.
68+
# When --sandbox is not used, shimmy behaves identically to the scratch image.
69+
FROM ubuntu:24.04
70+
71+
RUN apt-get update && apt-get install -y --no-install-recommends \
72+
libprotobuf32t64 \
73+
libnl-route-3-200 \
74+
libcap2 \
75+
ca-certificates \
76+
&& rm -rf /var/lib/apt/lists/*
77+
78+
COPY --from=nsjail-builder /nsjail-src/nsjail /usr/sbin/nsjail
2479
COPY --from=builder /app/bin/shimmy /shimmy
80+
81+
ENTRYPOINT ["/shimmy"]

Makefile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ GOLDFLAGS += -X main.Commit=$(COMMIT)
88
GOFLAGS = -ldflags "$(GOLDFLAGS)"
99

1010
BINARY_NAME ?= shimmy
11+
CONTAINER_ENGINE ?= docker
1112

12-
.PHONY: all build test test-unit lcov install generate-mocks update-schema
13+
.PHONY: all build test test-unit test-sandbox lcov install generate-mocks update-schema
1314

1415
all: build
1516

@@ -20,6 +21,18 @@ test: test-unit
2021

2122
test-unit:
2223
go test -covermode=count -coverprofile=coverage.out ./...
24+
25+
# Run sandbox integration tests inside a privileged container.
26+
# Supports Docker (default) and Podman: CONTAINER_ENGINE=podman make test-sandbox
27+
# On Linux with nsjail installed locally, use:
28+
# go test -v -run 'TestSandboxedWorker' ./internal/execution/worker/...
29+
test-sandbox:
30+
$(CONTAINER_ENGINE) build --target test-sandbox -t shimmy-test-sandbox .
31+
$(CONTAINER_ENGINE) run --rm --privileged \
32+
-v $(shell pwd):/workspace \
33+
-w /workspace \
34+
shimmy-test-sandbox \
35+
go test -v -run 'TestSandboxedWorker' ./internal/execution/worker/...
2336

2437
lcov:
2538
gcov2lcov -infile=coverage.out -outfile=lcov.info

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,77 @@ For example, a Wolfram Language evaluation function in `evaluation.wl` would be
244244
```shell
245245
wolframscript -file evaluation.wl /tmp/shimmy/abc/request-data-123 /tmp/shimmy/abc/response-data-456
246246
```
247+
248+
### Sandboxed Execution (Linux only, experimental)
249+
250+
Shimmy can wrap each worker process in an [nsjail](https://github.com/google/nsjail) sandbox to safely execute arbitrary, untrusted code. The sandbox provides:
251+
252+
- **Filesystem confinement** — the worker can only access explicitly bind-mounted paths
253+
- **Resource limits** — CPU time, memory, and file descriptor caps
254+
- **Network isolation** — optional; disables all outbound connections
255+
- **Unprivileged UID** — worker runs as `nobody` (uid 65534) inside the jail
256+
257+
Sandboxing requires Linux and the `nsjail` binary. The Docker image built from the project's `Dockerfile` includes nsjail at `/usr/sbin/nsjail`. On the host, install it with `sudo apt install nsjail` (Ubuntu 22.04+) or build from source.
258+
259+
Enable sandboxing with `--sandbox` and configure it with the flags below:
260+
261+
| Flag | Env var | Default | Description |
262+
|------|---------|---------|-------------|
263+
| `--sandbox` | `SANDBOX_ENABLED` | `false` | Enable nsjail sandboxing |
264+
| `--sandbox-nsjail-path` | `SANDBOX_NSJAIL_PATH` | `/usr/sbin/nsjail` | Path to the nsjail binary |
265+
| `--sandbox-ro-bind` | `SANDBOX_RO_BINDS` || Host path to bind-mount read-only (repeatable) |
266+
| `--sandbox-rw-bind` | `SANDBOX_RW_BINDS` || Host path to bind-mount read-write (repeatable) |
267+
| `--sandbox-tmpfs` | `SANDBOX_TMPFS` || Path inside the sandbox to mount as tmpfs (repeatable) |
268+
| `--sandbox-cpu-time` | `SANDBOX_CPU_TIME_LIMIT` | `0` (unlimited) | CPU time limit in seconds |
269+
| `--sandbox-memory-mb` | `SANDBOX_MEMORY_LIMIT` | `0` (unlimited) | Memory limit in megabytes |
270+
| `--sandbox-max-fds` | `SANDBOX_MAX_FDS` | `0` (nsjail default) | Maximum open file descriptors |
271+
| `--sandbox-disable-network` | `SANDBOX_DISABLE_NETWORK` | `false` | Disable network access inside the sandbox |
272+
| `--sandbox-seccomp` | `SANDBOX_SECCOMP` | `false` | Enable seccomp syscall filtering |
273+
274+
A typical invocation for an untrusted Python worker:
275+
276+
```shell
277+
shimmy -c python3 -a evaluation.py \
278+
--sandbox \
279+
--sandbox-ro-bind /usr \
280+
--sandbox-ro-bind /lib \
281+
--sandbox-ro-bind /lib64 \
282+
--sandbox-rw-bind /tmp/shimmy \
283+
--sandbox-cpu-time 30 \
284+
--sandbox-memory-mb 256 \
285+
--sandbox-disable-network
286+
```
287+
288+
> **Note:** nsjail requires either root or user namespace support. In Docker, pass `--privileged` or grant `CAP_SYS_ADMIN`. In Kubernetes, configure the pod's security context accordingly.
289+
290+
#### Testing sandboxing locally
291+
292+
The sandbox integration tests verify actual security properties — filesystem isolation, CPU limits, network isolation, and stdio passthrough. They skip automatically if `nsjail` is not available.
293+
294+
**On Linux with nsjail installed:**
295+
296+
```shell
297+
go test -v -run 'TestSandboxedWorker' ./internal/execution/worker/...
298+
```
299+
300+
**On macOS (or any platform) via Docker or Podman:**
301+
302+
```shell
303+
make test-sandbox # Docker (default)
304+
CONTAINER_ENGINE=podman make test-sandbox # Podman
305+
```
306+
307+
This builds the `nsjail-builder` Dockerfile stage (the same nsjail used in production) and runs the tests inside a privileged container. Rootless Podman works fine: `--privileged` grants all capabilities within the user namespace, which is sufficient for nsjail to create its own sub-namespaces.
308+
309+
To manually verify isolation, run the Docker image with a sandboxed worker that attempts to read a protected file:
310+
311+
```shell
312+
docker run --rm --privileged \
313+
-e FUNCTION_COMMAND=/bin/sh \
314+
-e FUNCTION_ARGS="-c,cat /etc/shadow" \
315+
-e SANDBOX_ENABLED=true \
316+
-e SANDBOX_RO_BINDS="/usr:/bin:/lib:/lib64" \
317+
ghcr.io/lambda-feedback/shimmy serve
318+
```
319+
320+
The worker should exit with a non-zero code because `/etc` is not mounted inside the sandbox.

cmd/root.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,74 @@ functions on arbitrary, serverless platforms.`
139139
Value: "127.0.0.1:7321",
140140
Category: "rpc",
141141
},
142+
// sandbox flags
143+
&cli.BoolFlag{
144+
Name: "sandbox",
145+
Usage: "enable nsjail sandboxing for worker processes (Linux only).",
146+
Value: false,
147+
Category: "sandbox",
148+
EnvVars: []string{"SANDBOX_ENABLED"},
149+
},
150+
&cli.StringFlag{
151+
Name: "sandbox-nsjail-path",
152+
Usage: "path to the nsjail binary.",
153+
Value: "/usr/sbin/nsjail",
154+
Category: "sandbox",
155+
EnvVars: []string{"SANDBOX_NSJAIL_PATH"},
156+
},
157+
&cli.StringSliceFlag{
158+
Name: "sandbox-ro-bind",
159+
Usage: "host path to bind-mount read-only inside the sandbox (repeatable).",
160+
Category: "sandbox",
161+
EnvVars: []string{"SANDBOX_RO_BINDS"},
162+
},
163+
&cli.StringSliceFlag{
164+
Name: "sandbox-rw-bind",
165+
Usage: "host path to bind-mount read-write inside the sandbox (repeatable).",
166+
Category: "sandbox",
167+
EnvVars: []string{"SANDBOX_RW_BINDS"},
168+
},
169+
&cli.StringSliceFlag{
170+
Name: "sandbox-tmpfs",
171+
Usage: "path inside the sandbox to mount as tmpfs (repeatable).",
172+
Category: "sandbox",
173+
EnvVars: []string{"SANDBOX_TMPFS"},
174+
},
175+
&cli.IntFlag{
176+
Name: "sandbox-cpu-time",
177+
Usage: "CPU time limit in seconds for worker processes (0 = unlimited).",
178+
Value: 0,
179+
Category: "sandbox",
180+
EnvVars: []string{"SANDBOX_CPU_TIME_LIMIT"},
181+
},
182+
&cli.IntFlag{
183+
Name: "sandbox-memory-mb",
184+
Usage: "memory (address space) limit in megabytes for worker processes (0 = unlimited).",
185+
Value: 0,
186+
Category: "sandbox",
187+
EnvVars: []string{"SANDBOX_MEMORY_LIMIT"},
188+
},
189+
&cli.IntFlag{
190+
Name: "sandbox-max-fds",
191+
Usage: "maximum open file descriptors for worker processes (0 = nsjail default).",
192+
Value: 0,
193+
Category: "sandbox",
194+
EnvVars: []string{"SANDBOX_MAX_FDS"},
195+
},
196+
&cli.BoolFlag{
197+
Name: "sandbox-disable-network",
198+
Usage: "disable network access inside the sandbox.",
199+
Value: false,
200+
Category: "sandbox",
201+
EnvVars: []string{"SANDBOX_DISABLE_NETWORK"},
202+
},
203+
&cli.BoolFlag{
204+
Name: "sandbox-seccomp",
205+
Usage: "enable seccomp syscall filtering inside the sandbox.",
206+
Value: false,
207+
Category: "sandbox",
208+
EnvVars: []string{"SANDBOX_SECCOMP"},
209+
},
142210
},
143211
Before: func(ctx *cli.Context) error {
144212
// create the logger
@@ -263,6 +331,17 @@ func parseRootConfig(ctx *cli.Context) (config.Config, error) {
263331
"rpc-transport-tcp-address": "runtime.io.rpc.tcp.address",
264332
"worker-send-timeout": "runtime.send.timeout",
265333
"worker-stop-timeout": "runtime.stop.timeout",
334+
// sandbox
335+
"sandbox": "runtime.sandbox.enabled",
336+
"sandbox-nsjail-path": "runtime.sandbox.nsjail_path",
337+
"sandbox-ro-bind": "runtime.sandbox.ro_binds",
338+
"sandbox-rw-bind": "runtime.sandbox.rw_binds",
339+
"sandbox-tmpfs": "runtime.sandbox.tmpfs",
340+
"sandbox-cpu-time": "runtime.sandbox.cpu_time_limit",
341+
"sandbox-memory-mb": "runtime.sandbox.memory_limit",
342+
"sandbox-max-fds": "runtime.sandbox.max_fds",
343+
"sandbox-disable-network": "runtime.sandbox.disable_network",
344+
"sandbox-seccomp": "runtime.sandbox.seccomp",
266345
}
267346

268347
// parse config using env

internal/execution/supervisor/adapter_file.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,21 @@ func (a *fileAdapter) Send(
8888
return nil, fmt.Errorf("error creating temp dir: %w", err)
8989
}
9090

91+
// allow sandboxed workers running as an unprivileged user to enter the dir
92+
if err := os.Chmod(tmpPath, 0755); err != nil {
93+
return nil, fmt.Errorf("error setting temp dir permissions: %w", err)
94+
}
95+
9196
// create temp files for request and response data
9297
reqFile, err := os.CreateTemp(tmpPath, "request-data-*")
9398
if err != nil {
9499
return nil, fmt.Errorf("error creating temp file: %w", err)
95100
}
101+
102+
// allow sandboxed workers (running as nobody) to read the request file
103+
if err := os.Chmod(reqFile.Name(), 0644); err != nil {
104+
return nil, fmt.Errorf("error setting request file permissions: %w", err)
105+
}
96106
defer func() {
97107
if err := os.Remove(reqFile.Name()); err != nil {
98108
a.log.Error("failed to remove request file", zap.Error(err))
@@ -104,6 +114,11 @@ func (a *fileAdapter) Send(
104114
return nil, fmt.Errorf("error creating temp file: %w", err)
105115
}
106116

117+
// allow sandboxed workers (running as nobody) to write the response file
118+
if err := os.Chmod(resFile.Name(), 0622); err != nil {
119+
return nil, fmt.Errorf("error setting response file permissions: %w", err)
120+
}
121+
107122
defer func() {
108123
if err := resFile.Close(); err != nil {
109124
a.log.Error("failed to close response file", zap.Error(err))

internal/execution/supervisor/adapter_rpc_pipe.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"sync"
1010
)
1111

12+
const maxContentLength = 64 * 1024 * 1024 // 64 MB
13+
1214
// headerPrefixPipe wraps another io.ReadWriteCloser and adds LSP-style headers
1315
type headerPrefixPipe struct {
1416
stdio io.ReadWriteCloser
@@ -67,6 +69,9 @@ func (h *headerPrefixPipe) Read(p []byte) (int, error) {
6769
if err != nil {
6870
return 0, fmt.Errorf("invalid Content-Length value: %s", parts[1])
6971
}
72+
if v < 0 || v > maxContentLength {
73+
return 0, fmt.Errorf("Content-Length out of range: %d", v)
74+
}
7075
contentLength = v
7176
break
7277
}

internal/execution/supervisor/adapter_rpc_pipe_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@ func TestHeaderPrefixPipe_MultipleMessages(t *testing.T) {
107107
assert.Equal(t, msg2, readBuf[:n])
108108
}
109109

110+
func TestHeaderPrefixPipe_RejectsOversizedContentLength(t *testing.T) {
111+
buf := newRwc()
112+
pipe := &headerPrefixPipe{stdio: buf}
113+
114+
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", maxContentLength+1)
115+
buf.(*rwc).Buffer.Write([]byte(header))
116+
117+
_, err := pipe.Read(make([]byte, 512))
118+
assert.ErrorContains(t, err, "Content-Length out of range")
119+
}
120+
121+
func TestHeaderPrefixPipe_RejectsNegativeContentLength(t *testing.T) {
122+
buf := newRwc()
123+
pipe := &headerPrefixPipe{stdio: buf}
124+
125+
buf.(*rwc).Buffer.Write([]byte("Content-Length: -1\r\n\r\n"))
126+
127+
_, err := pipe.Read(make([]byte, 512))
128+
assert.ErrorContains(t, err, "Content-Length out of range")
129+
}
130+
110131
func TestHeaderPrefixPipe_SkipsStrayOutputBeforeContentLength(t *testing.T) {
111132
buf := newRwc()
112133
pipe := &headerPrefixPipe{stdio: buf}

0 commit comments

Comments
 (0)