fix: Avoid bind mount for non-existentent deny protection

This commit is contained in:
abhisek
2026-01-15 09:33:57 +05:30
parent 5098649c72
commit fb70861532
2 changed files with 26 additions and 84 deletions
+12 -37
View File
@@ -224,14 +224,7 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox
args = append(args, writeArgs...) args = append(args, writeArgs...)
} }
// Collect allowed write paths for deny rule processing // 3. Process deny_write rules (mount /dev/null to prevent access)
// This is used to determine if non-existent deny paths need to be blocked
allowedWritePaths := make([]string, 0, len(writeBoundPaths))
for path := range writeBoundPaths {
allowedWritePaths = append(allowedWritePaths, path)
}
// 3. Process deny_write rules (mount /dev/null to prevent creation)
allowGitConfig := utils.SafelyGetValue(policy.AllowGitConfig) allowGitConfig := utils.SafelyGetValue(policy.AllowGitConfig)
denyPatterns := append([]string{}, policy.Filesystem.DenyWrite...) denyPatterns := append([]string{}, policy.Filesystem.DenyWrite...)
@@ -246,7 +239,7 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox
continue continue
} }
denyArgs, err := t.processDenyRule(expanded, allowedWritePaths) denyArgs, err := t.processDenyRule(expanded)
if err != nil { if err != nil {
log.Debugf("Deny rule '%s' skipped: %v", expanded, err) log.Debugf("Deny rule '%s' skipped: %v", expanded, err)
continue continue
@@ -437,10 +430,9 @@ func (t *bubblewrapPolicyTranslator) processWriteRule(path string, boundPaths ma
return args, nil return args, nil
} }
// processDenyRule handles deny rules by mounting /dev/null to prevent file creation. // processDenyRule handles deny rules by mounting /dev/null to prevent file access.
// This technique is borrowed from Anthropic's sandbox-runtime. // This technique is borrowed from Anthropic's sandbox-runtime.
// allowedWritePaths is used to determine if non-existent paths need to be blocked. func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, error) {
func (t *bubblewrapPolicyTranslator) processDenyRule(path string, allowedWritePaths []string) ([]string, error) {
args := []string{} args := []string{}
// For glob patterns, expand and deny each path // For glob patterns, expand and deny each path
@@ -481,18 +473,14 @@ func (t *bubblewrapPolicyTranslator) processDenyRule(path string, allowedWritePa
args = append(args, "--ro-bind", "/dev/null", path) args = append(args, "--ro-bind", "/dev/null", path)
} }
} else if os.IsNotExist(err) { } else if os.IsNotExist(err) {
// File doesn't exist - check if it's within an allowed write path // File doesn't exist - skip it
// If so, we need to block creation by mounting /dev/null at first non-existent component // IMPORTANT: We cannot use --ro-bind /dev/null for non-existent paths because
if isWithinAllowedWritePath(path, allowedWritePaths) { // bwrap creates the file on the host filesystem as a mount point, which leaves
firstNonExistent := t.findFirstNonExistentPath(path) // empty files (.env, .aws, etc.) in the user's directory after sandbox exits.
if firstNonExistent != "" { // Non-existent files are harmless (no secrets to leak), and blocking creation
args = append(args, "--ro-bind", "/dev/null", firstNonExistent) // in writable directories isn't critical since an attacker creating an empty
log.Debugf("Deny rule: blocking creation of '%s' by mounting /dev/null at '%s'", path, firstNonExistent) // .env is not a security threat.
} log.Debugf("Deny rule: skipping non-existent path '%s' (bwrap would create empty file as mount point)", path)
} else {
// Not within allowed write paths - already protected by deny-by-default
log.Debugf("Deny rule: skipping '%s' - not within allowed write paths (protected by deny-by-default)", path)
}
} }
} }
@@ -522,19 +510,6 @@ func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) strin
return "" return ""
} }
// isWithinAllowedWritePath checks if a target path is within any of the allowed write paths.
// This is used to determine if a non-existent deny path needs to be blocked.
func isWithinAllowedWritePath(targetPath string, allowedWritePaths []string) bool {
targetPath = filepath.Clean(targetPath)
for _, allowedPath := range allowedWritePaths {
allowedPath = filepath.Clean(allowedPath)
if targetPath == allowedPath || strings.HasPrefix(targetPath, allowedPath+"/") {
return true
}
}
return false
}
// expandGlobPattern expands a glob pattern to a list of concrete paths. // expandGlobPattern expands a glob pattern to a list of concrete paths.
// Implements depth limiting and path count limiting to prevent DoS. // Implements depth limiting and path count limiting to prevent DoS.
// Returns (paths, useFallback, error) where useFallback indicates if // Returns (paths, useFallback, error) where useFallback indicates if
@@ -506,35 +506,11 @@ func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
assert.Contains(t, argsStr, testFile) assert.Contains(t, argsStr, testFile)
}) })
t.Run("non-existent file outside allowed write paths is skipped", func(t *testing.T) { t.Run("non-existent file is skipped to avoid creating empty files", func(t *testing.T) {
// Use a path that is truly outside any allowed write path
// Note: /tmp is always in allowedWritePaths due to tmpdir support
// So we use a path under /nonexistent which doesn't exist
nonExistentPath := "/nonexistent/completely/fake/path/.env"
policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{
DenyWrite: []string{nonExistentPath},
// No AllowWrite - path is not within any allowed write area
},
}
config := newDefaultBubblewrapConfig()
translator := newBubblewrapPolicyTranslator(config)
args, err := translator.translate(policy)
require.NoError(t, err)
argsStr := argSliceToString(args)
// Non-existent file should NOT be in args (protected by deny-by-default)
// Path is not within allowed write paths, so no need to block it
assert.NotContains(t, argsStr, nonExistentPath)
})
t.Run("non-existent file within allowed write path is blocked", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Non-existent path WITHIN an allowed write path // Non-existent file - should be skipped because using --ro-bind /dev/null
// on non-existent paths causes bwrap to create the file as a mount point
nonExistentPath := filepath.Join(tmpDir, ".env") nonExistentPath := filepath.Join(tmpDir, ".env")
policy := &sandbox.SandboxPolicy{ policy := &sandbox.SandboxPolicy{
@@ -551,29 +527,21 @@ func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
argsStr := argSliceToString(args) argsStr := argSliceToString(args)
// Non-existent file within allowed write path should be blocked // Non-existent file should NOT be in args
// by mounting /dev/null at the first non-existent component // Using --ro-bind /dev/null on non-existent paths creates empty files
assert.Contains(t, argsStr, "--ro-bind") assert.NotContains(t, argsStr, nonExistentPath)
assert.Contains(t, argsStr, "/dev/null")
assert.Contains(t, argsStr, nonExistentPath)
}) })
t.Run("deeply nested non-existent path is blocked at first missing component", func(t *testing.T) { t.Run("existing directory is mounted read-only", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
// Create a subdirectory that exists // Create a directory
existingDir := filepath.Join(tmpDir, "existing") testDir := filepath.Join(tmpDir, "secrets")
require.NoError(t, os.MkdirAll(existingDir, 0755)) require.NoError(t, os.MkdirAll(testDir, 0755))
// Deeply nested non-existent path: existing/nonexistent/deep/.env
// First non-existent component is "nonexistent"
nonExistentPath := filepath.Join(existingDir, "nonexistent", "deep", ".env")
firstNonExistent := filepath.Join(existingDir, "nonexistent")
policy := &sandbox.SandboxPolicy{ policy := &sandbox.SandboxPolicy{
Filesystem: sandbox.FilesystemPolicy{ Filesystem: sandbox.FilesystemPolicy{
AllowWrite: []string{tmpDir}, // Allow writes to tmpDir DenyWrite: []string{testDir},
DenyWrite: []string{nonExistentPath},
}, },
} }
@@ -584,10 +552,9 @@ func TestBubblewrapTranslatorProcessDenyRule(t *testing.T) {
argsStr := argSliceToString(args) argsStr := argSliceToString(args)
// Should mount /dev/null at the first non-existent component // Existing directory should be mounted read-only
assert.Contains(t, argsStr, "--ro-bind") assert.Contains(t, argsStr, "--ro-bind-try")
assert.Contains(t, argsStr, "/dev/null") assert.Contains(t, argsStr, testDir)
assert.Contains(t, argsStr, firstNonExistent)
}) })
} }