Skip to content

Commit 5f6ba39

Browse files
committed
security: confine sandbox volume mounts to working directory
- Add sanitizeVolumeMount in internal/sandbox/sandbox.go. - Resolve host paths to absolute, reject .. components and symlinks. - Require extra volume host paths to be inside the working directory. - Expand ForbiddenMountPrefixes with /var, /run, /root, /home, and /var/run/docker.sock. - Rewrite mounts with canonical absolute host paths so Docker does not interpret relative paths relative to the daemon's cwd. - Update affected tests in cmd/odek/main_test.go and internal/sandbox tests. - Document the sandbox_volumes restriction and fix examples in docs/SANDBOXING.md. Full suite: go test ./... -count=1 passes.
1 parent d6895c6 commit 5f6ba39

4 files changed

Lines changed: 181 additions & 32 deletions

File tree

cmd/odek/main_test.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,7 +1151,7 @@ func TestBuildSandboxArgs_EnvAndVolumes(t *testing.T) {
11511151
"GOCACHE": "/tmp/gocache",
11521152
"NODE_ENV": "test",
11531153
},
1154-
Volumes: []string{"/host/cache:/container/cache", "/host/data:/data:ro"},
1154+
Volumes: []string{"/tmp/workdir/cache:/container/cache", "/tmp/workdir/data:/data:ro"},
11551155
}
11561156
args := sandbox.BuildRunArgs(cfg, "odek-test", "/tmp/workdir", cfg.Image)
11571157

@@ -1163,12 +1163,13 @@ func TestBuildSandboxArgs_EnvAndVolumes(t *testing.T) {
11631163
t.Error("missing env var NODE_ENV=test in docker args")
11641164
}
11651165

1166-
// Must contain volume mounts as "-v HOST:CONTAINER" pairs
1167-
if !hasArgPair(args, "-v", "/host/cache:/container/cache") {
1168-
t.Error("missing volume /host/cache:/container/cache in docker args")
1166+
// Must contain volume mounts as "-v HOST:CONTAINER" pairs.
1167+
// With the security fix, extra volume host paths must stay inside workdir.
1168+
if !hasArgPair(args, "-v", "/tmp/workdir/cache:/container/cache") {
1169+
t.Error("missing volume /tmp/workdir/cache:/container/cache in docker args")
11691170
}
1170-
if !hasArgPair(args, "-v", "/host/data:/data:ro") {
1171-
t.Error("missing volume /host/data:/data:ro in docker args")
1171+
if !hasArgPair(args, "-v", "/tmp/workdir/data:/data:ro") {
1172+
t.Error("missing volume /tmp/workdir/data:/data:ro in docker args")
11721173
}
11731174
}
11741175

@@ -1724,7 +1725,7 @@ func TestBuildSandboxArgs_WithResources(t *testing.T) {
17241725
CPUs: "0.5",
17251726
User: "1000:1000",
17261727
Env: map[string]string{"FOO": "bar"},
1727-
Volumes: []string{"/data:/data"},
1728+
Volumes: []string{"/workspace/data:/data"},
17281729
}, "odek-test", "/workspace", "alpine:latest")
17291730
full := strings.Join(args, " ")
17301731
if !strings.Contains(full, "--memory") || !strings.Contains(full, "512m") {
@@ -1739,7 +1740,7 @@ func TestBuildSandboxArgs_WithResources(t *testing.T) {
17391740
if !strings.Contains(full, "FOO=bar") {
17401741
t.Error("should include env var")
17411742
}
1742-
if !strings.Contains(full, "/data:/data") {
1743+
if !strings.Contains(full, "/workspace/data:/data") {
17431744
t.Error("should include extra volume")
17441745
}
17451746
}
@@ -1868,15 +1869,16 @@ func TestBuildSandboxArgs_AllForbiddenPrefixes(t *testing.T) {
18681869
}
18691870
}
18701871

1871-
// TestBuildSandboxArgs_ValidVolume verifies a non-forbidden volume IS included.
1872+
// TestBuildSandboxArgs_ValidVolume verifies a non-forbidden volume under the
1873+
// working directory IS included.
18721874
func TestBuildSandboxArgs_ValidVolume(t *testing.T) {
18731875
cfg := sandboxConfig{
18741876
Network: "bridge",
1875-
Volumes: []string{"/data:/data"},
1877+
Volumes: []string{"/workspace/data:/data"},
18761878
}
18771879
args := sandbox.BuildRunArgs(cfg, "odek-test", "/workspace", "alpine:latest")
1878-
if !hasArgPair(args, "-v", "/data:/data") {
1879-
t.Error("valid volume /data:/data should be included in docker args")
1880+
if !hasArgPair(args, "-v", "/workspace/data:/data") {
1881+
t.Error("valid volume /workspace/data:/data should be included in docker args")
18801882
}
18811883
}
18821884

docs/SANDBOXING.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ All sandbox settings are available in `~/.odek/config.json`, `./odek.json`, `ODE
4141
"NODE_ENV": "development"
4242
},
4343
"sandbox_volumes": [
44-
"/home/user/.npm:/root/.npm"
44+
"./.npm:/root/.npm"
4545
]
4646
}
4747
```
@@ -61,6 +61,12 @@ All sandbox settings are available in `~/.odek/config.json`, `./odek.json`, `ODE
6161
| `sandbox_volumes` ||| array | `[]` | Extra volume mounts (`host:container`) |
6262

6363
> **Note:** `sandbox_env` and `sandbox_volumes` are config-file-only — they're too complex for flat env vars or CLI flags. For all other fields, env vars and CLI flags follow the standard `ODEK_*` pattern.
64+
>
65+
> **Security restriction on `sandbox_volumes`:** Extra volume host paths must be
66+
> inside the working directory. Absolute paths outside the project (e.g.
67+
> `/var/run/docker.sock`, `/etc`, `/home/user/...`) and paths containing `..`
68+
> or symlinks are rejected. Relative paths are resolved relative to the working
69+
> directory and must stay inside it.
6470
6571
### Env var examples
6672

@@ -232,7 +238,7 @@ odek's sandbox follows the principle of **least privilege with progressive opt-i
232238
"NPM_CONFIG_CACHE": "/tmp/.npm"
233239
},
234240
"sandbox_volumes": [
235-
"/root/.npm:/root/.npm"
241+
"./.npm:/root/.npm"
236242
]
237243
}
238244
```

internal/sandbox/sandbox.go

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ const DockerfileName = "Dockerfile.odek"
2929
// touch. A user could accidentally (or, in --task scenarios, be coaxed
3030
// into) requesting one of these and undo the whole point of sandboxing.
3131
// Mounts to these paths are dropped with a stderr warning.
32-
var ForbiddenMountPrefixes = []string{"/", "/etc", "/proc", "/sys", "/boot", "/dev"}
32+
var ForbiddenMountPrefixes = []string{
33+
"/", "/etc", "/proc", "/sys", "/boot", "/dev",
34+
"/var", "/run", "/root", "/home",
35+
"/var/run/docker.sock",
36+
}
3337

3438
// Config is the resolved sandbox configuration for one agent run. All
3539
// fields come from the merged config (files → env → CLI) — this struct
@@ -135,27 +139,122 @@ func BuildRunArgs(cfg Config, containerName, workdir, image string) []string {
135139
}
136140

137141
for _, vol := range cfg.Volumes {
138-
reject := false
139-
parts := strings.SplitN(vol, ":", 2)
140-
if len(parts) > 0 {
141-
hostPath := filepath.Clean(parts[0])
142-
for _, forbidden := range ForbiddenMountPrefixes {
143-
if hostPath == forbidden || strings.HasPrefix(hostPath, forbidden+"/") {
144-
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting forbidden volume mount %q (host path %s)\n", vol, hostPath)
145-
reject = true
146-
break
147-
}
148-
}
149-
}
150-
if !reject {
151-
args = append(args, "-v", vol)
142+
sanitized, ok := sanitizeVolumeMount(vol, workdir)
143+
if !ok {
144+
continue
152145
}
146+
args = append(args, "-v", sanitized)
153147
}
154148

155149
args = append(args, image, "sleep", "infinity")
156150
return args
157151
}
158152

153+
// sanitizeVolumeMount validates and canonicalises a user-supplied docker -v
154+
// string. It returns the canonical mount string and true if the mount is
155+
// allowed, or an empty string and false if it should be dropped.
156+
//
157+
// Security rules:
158+
// - The host path must be absolute or a local path under workdir.
159+
// - Any ".." component is rejected.
160+
// - The resolved host path must be inside workdir.
161+
// - The resolved host path must not match ForbiddenMountPrefixes.
162+
// - Symlinks are rejected (they could point outside workdir).
163+
//
164+
// The returned string uses the resolved absolute host path so Docker does not
165+
// interpret a relative path relative to the daemon's working directory.
166+
func sanitizeVolumeMount(vol, workdir string) (string, bool) {
167+
// Docker volume format: host[:container[:options]]. We only need the host.
168+
parts := strings.SplitN(vol, ":", 2)
169+
host := strings.TrimSpace(parts[0])
170+
if host == "" {
171+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting malformed volume mount %q (empty host path)\n", vol)
172+
return "", false
173+
}
174+
175+
// Reject paths with traversal attempts before resolving them.
176+
if hasDotDotComponent(host) {
177+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting volume mount %q (contains ..)\n", vol)
178+
return "", false
179+
}
180+
181+
// Resolve to an absolute path. Relative paths are interpreted relative to
182+
// the current working directory, which should be the project root.
183+
absHost := host
184+
if !filepath.IsAbs(absHost) {
185+
absHost = filepath.Join(workdir, absHost)
186+
}
187+
absHost = filepath.Clean(absHost)
188+
189+
absWorkdir, err := filepath.Abs(workdir)
190+
if err != nil {
191+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting volume mount %q (cannot resolve workdir: %v)\n", vol, err)
192+
return "", false
193+
}
194+
absWorkdir = filepath.Clean(absWorkdir)
195+
196+
// The host path must stay inside the working directory.
197+
if !isPathUnder(absHost, absWorkdir) {
198+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting volume mount %q (host path %s is outside working directory %s)\n", vol, absHost, absWorkdir)
199+
return "", false
200+
}
201+
202+
// Reject symlinks — they could escape the working directory even if the
203+
// link itself is inside it.
204+
if info, err := os.Lstat(absHost); err == nil {
205+
if info.Mode()&os.ModeSymlink != 0 {
206+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting volume mount %q (symlinks are not allowed)\n", vol)
207+
return "", false
208+
}
209+
}
210+
211+
// Reject forbidden host paths.
212+
for _, forbidden := range ForbiddenMountPrefixes {
213+
if absHost == forbidden {
214+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting forbidden volume mount %q (host path %s)\n", vol, absHost)
215+
return "", false
216+
}
217+
if strings.HasPrefix(absHost, forbidden+string(filepath.Separator)) {
218+
fmt.Fprintf(os.Stderr, "odek: WARNING: rejecting forbidden volume mount %q (host path %s)\n", vol, absHost)
219+
return "", false
220+
}
221+
}
222+
223+
// Rebuild the mount with the canonical absolute host path.
224+
rest := ""
225+
if len(parts) == 2 {
226+
rest = ":" + parts[1]
227+
}
228+
return absHost + rest, true
229+
}
230+
231+
// hasDotDotComponent reports whether p contains a ".." path component after
232+
// cleaning. It allows names that merely contain ".." as a substring (e.g.
233+
// "foo..bar") but rejects any traversal attempt.
234+
func hasDotDotComponent(p string) bool {
235+
clean := filepath.Clean(p)
236+
for _, part := range strings.Split(clean, string(filepath.Separator)) {
237+
if part == ".." {
238+
return true
239+
}
240+
}
241+
return false
242+
}
243+
244+
// isPathUnder reports whether path is inside root. Both paths must be clean
245+
// absolute paths. A path equal to root is considered under root.
246+
func isPathUnder(path, root string) bool {
247+
rel, err := filepath.Rel(root, path)
248+
if err != nil {
249+
return false
250+
}
251+
if rel == "." {
252+
return true
253+
}
254+
// filepath.Rel returns paths starting with ".." when path is outside root.
255+
return !strings.HasPrefix(rel, "..") && !strings.Contains(filepath.ToSlash(rel), "/../")
256+
}
257+
159258
// InjectFiles copies each file under cwd into a running container via
160259
// `docker cp`. Files inside cwd preserve their relative path; absolute
161260
// paths outside cwd are placed by basename at /workspace/. Nested paths

internal/sandbox/sandbox_test.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,57 @@ func TestBuildRunArgs_ReadonlyAppendsRoSuffix(t *testing.T) {
134134

135135
func TestBuildRunArgs_ForbiddenVolumeMountRejected(t *testing.T) {
136136
args := BuildRunArgs(Config{
137-
Volumes: []string{"/etc:/container/etc", "/safe:/safe"},
137+
Volumes: []string{"/etc:/container/etc", "/workspace/extra:/container/extra"},
138138
}, "odek-test", "/workspace", "alpine:latest")
139139
for _, a := range args {
140140
if strings.HasPrefix(a, "/etc:") {
141141
t.Errorf("forbidden /etc mount should have been rejected, found %q", a)
142142
}
143143
}
144-
if !contains(args, "/safe:/safe") {
145-
t.Errorf("safe mount %q should have been preserved\nargs: %v", "/safe:/safe", args)
144+
if !contains(args, "/workspace/extra:/container/extra") {
145+
t.Errorf("safe mount %q should have been preserved\nargs: %v", "/workspace/extra:/container/extra", args)
146+
}
147+
}
148+
149+
func TestBuildRunArgs_VolumeOutsideWorkdirRejected(t *testing.T) {
150+
args := BuildRunArgs(Config{
151+
Volumes: []string{"/tmp:/container/tmp"},
152+
}, "odek-test", "/workspace", "alpine:latest")
153+
for i, a := range args {
154+
if a == "-v" && i+1 < len(args) && strings.HasPrefix(args[i+1], "/tmp:") {
155+
t.Errorf("volume outside workdir should have been rejected, found %q", args[i+1])
156+
}
157+
}
158+
}
159+
160+
func TestBuildRunArgs_RelativeTraversalRejected(t *testing.T) {
161+
args := BuildRunArgs(Config{
162+
Volumes: []string{"../../etc:/container/etc"},
163+
}, "odek-test", "/workspace", "alpine:latest")
164+
for i, a := range args {
165+
if a == "-v" && i+1 < len(args) && strings.Contains(args[i+1], "etc") {
166+
t.Errorf("relative traversal volume should have been rejected, found %q", args[i+1])
167+
}
168+
}
169+
}
170+
171+
func TestBuildRunArgs_DockerSocketRejected(t *testing.T) {
172+
args := BuildRunArgs(Config{
173+
Volumes: []string{"/var/run/docker.sock:/var/run/docker.sock"},
174+
}, "odek-test", "/workspace", "alpine:latest")
175+
for i, a := range args {
176+
if a == "-v" && i+1 < len(args) && strings.Contains(args[i+1], "docker.sock") {
177+
t.Errorf("docker socket mount should have been rejected, found %q", args[i+1])
178+
}
179+
}
180+
}
181+
182+
func TestBuildRunArgs_RelativeVolumeResolvedUnderWorkdir(t *testing.T) {
183+
args := BuildRunArgs(Config{
184+
Volumes: []string{"extra:/container/extra"},
185+
}, "odek-test", "/workspace", "alpine:latest")
186+
if !contains(args, "/workspace/extra:/container/extra") {
187+
t.Errorf("relative volume should be resolved to absolute under workdir\nargs: %v", args)
146188
}
147189
}
148190

0 commit comments

Comments
 (0)