mirror of
https://github.com/anthropic-experimental/sandbox-runtime.git
synced 2026-06-06 23:06:43 +08:00
Fix dotfile leak by skipping non-existent deny paths (#91)
Mounting /dev/null over non-existent paths creates empty files on host. Skip non-existent deny paths instead of mounting /dev/null over them. Removed: - Non-existent path protection code that created empty files - findFirstNonExistentComponent() function - Non-existent deny path protection tests Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
54ecea4d47
commit
85e1515395
@@ -100,30 +100,6 @@ function findSymlinkInPath(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first non-existent path component.
|
||||
* E.g., for "/existing/parent/nonexistent/child/file.txt" where /existing/parent exists,
|
||||
* returns "/existing/parent/nonexistent"
|
||||
*
|
||||
* This is used to block creation of non-existent deny paths by mounting /dev/null
|
||||
* at the first missing component, preventing mkdir from creating the parent directories.
|
||||
*/
|
||||
function findFirstNonExistentComponent(targetPath: string): string {
|
||||
const parts = targetPath.split(path.sep)
|
||||
let currentPath = ''
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue // Skip empty parts (leading /)
|
||||
const nextPath = currentPath + path.sep + part
|
||||
if (!fs.existsSync(nextPath)) {
|
||||
return nextPath
|
||||
}
|
||||
currentPath = nextPath
|
||||
}
|
||||
|
||||
return targetPath // Shouldn't reach here if called correctly
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mandatory deny paths using ripgrep (Linux only).
|
||||
* Uses a SINGLE ripgrep call with multiple glob patterns for efficiency.
|
||||
@@ -627,36 +603,12 @@ async function generateFilesystemArgs(
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle non-existent paths by mounting /dev/null to block creation
|
||||
// Skip non-existent paths - no protection needed
|
||||
// Mounting /dev/null over non-existent paths creates empty files on host
|
||||
if (!fs.existsSync(normalizedPath)) {
|
||||
// Find the deepest existing ancestor directory
|
||||
let ancestorPath = path.dirname(normalizedPath)
|
||||
while (ancestorPath !== '/' && !fs.existsSync(ancestorPath)) {
|
||||
ancestorPath = path.dirname(ancestorPath)
|
||||
}
|
||||
|
||||
// Only protect if the existing ancestor is within an allowed write path
|
||||
const ancestorIsWithinAllowedPath = allowedWritePaths.some(
|
||||
allowedPath =>
|
||||
ancestorPath.startsWith(allowedPath + '/') ||
|
||||
ancestorPath === allowedPath ||
|
||||
normalizedPath.startsWith(allowedPath + '/'),
|
||||
logForDebugging(
|
||||
`[Sandbox Linux] Skipping non-existent deny path: ${normalizedPath}`,
|
||||
)
|
||||
|
||||
if (ancestorIsWithinAllowedPath) {
|
||||
// Mount /dev/null at the first non-existent path component
|
||||
// This blocks creation of the entire path by making the first
|
||||
// missing component appear as an empty file (mkdir will fail)
|
||||
const firstNonExistent = findFirstNonExistentComponent(normalizedPath)
|
||||
args.push('--ro-bind', '/dev/null', firstNonExistent)
|
||||
logForDebugging(
|
||||
`[Sandbox Linux] Mounted /dev/null at ${firstNonExistent} to block creation of ${normalizedPath}`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[Sandbox Linux] Skipping non-existent deny path not within allowed paths: ${normalizedPath}`,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -486,100 +486,6 @@ describe('Mandatory Deny Paths - Integration Tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-existent deny path protection (Linux only)', () => {
|
||||
// This tests the fix for sandbox escape via creating non-existent deny paths
|
||||
// Only applicable to Linux since it uses /dev/null mounting
|
||||
|
||||
async function runSandboxedWriteWithDenyPaths(
|
||||
command: string,
|
||||
denyPaths: string[],
|
||||
): Promise<{ success: boolean; stdout: string; stderr: string }> {
|
||||
const platform = getPlatform()
|
||||
if (platform !== 'linux') {
|
||||
return { success: true, stdout: '', stderr: '' }
|
||||
}
|
||||
|
||||
const writeConfig = {
|
||||
allowOnly: ['.'],
|
||||
denyWithinAllow: denyPaths,
|
||||
}
|
||||
|
||||
const wrappedCommand = await wrapCommandWithSandboxLinux({
|
||||
command,
|
||||
needsNetworkRestriction: false,
|
||||
readConfig: undefined,
|
||||
writeConfig,
|
||||
enableWeakerNestedSandbox: true,
|
||||
})
|
||||
|
||||
const result = spawnSync(wrappedCommand, {
|
||||
shell: true,
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
return {
|
||||
success: result.status === 0,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
}
|
||||
}
|
||||
|
||||
it('blocks creation of non-existent file when parent dir exists', async () => {
|
||||
if (getPlatform() !== 'linux') return
|
||||
|
||||
// .claude directory exists from beforeAll setup
|
||||
// .claude/settings.json does NOT exist
|
||||
const nonExistentFile = '.claude/settings.json'
|
||||
|
||||
const result = await runSandboxedWriteWithDenyPaths(
|
||||
`echo '{"hooks":{}}' > '${nonExistentFile}'`,
|
||||
[join(TEST_DIR, nonExistentFile)],
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
// Verify file content was NOT written (bwrap may create empty mount point file)
|
||||
const content = readFileSync(nonExistentFile, 'utf8')
|
||||
expect(content).toBe('')
|
||||
})
|
||||
|
||||
it('blocks creation of non-existent file when parent dir also does not exist', async () => {
|
||||
if (getPlatform() !== 'linux') return
|
||||
|
||||
// nonexistent-dir does NOT exist
|
||||
const nonExistentPath = 'nonexistent-dir/settings.json'
|
||||
|
||||
const result = await runSandboxedWriteWithDenyPaths(
|
||||
`mkdir -p nonexistent-dir && echo '{"hooks":{}}' > '${nonExistentPath}'`,
|
||||
[join(TEST_DIR, nonExistentPath)],
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
// bwrap mounts /dev/null at first non-existent component, blocking mkdir
|
||||
// The mount point file is created but is empty (from /dev/null)
|
||||
const content = readFileSync('nonexistent-dir', 'utf8')
|
||||
expect(content).toBe('')
|
||||
})
|
||||
|
||||
it('blocks creation of deeply nested non-existent path', async () => {
|
||||
if (getPlatform() !== 'linux') return
|
||||
|
||||
// a/b/c/file.txt does NOT exist
|
||||
const nonExistentPath = 'a/b/c/file.txt'
|
||||
|
||||
const result = await runSandboxedWriteWithDenyPaths(
|
||||
`mkdir -p a/b/c && echo 'test' > '${nonExistentPath}'`,
|
||||
[join(TEST_DIR, nonExistentPath)],
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
// bwrap mounts /dev/null at 'a' (first non-existent component), blocking mkdir
|
||||
// The mount point file is created but is empty (from /dev/null)
|
||||
const content = readFileSync('a', 'utf8')
|
||||
expect(content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Symlink replacement attack protection (Linux only)', () => {
|
||||
// This tests the fix for symlink replacement attacks where an attacker
|
||||
// could delete a symlink and create a real directory with malicious content
|
||||
|
||||
Reference in New Issue
Block a user