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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,15 +512,16 @@ While `filterRequest` is set, `TRACE` and `TRACK` requests are answered 405 befo

**Unix Socket Settings** (platform-specific behavior):

| Setting | macOS | Linux |
| ------------------------------ | ------------------------- | ---------------------------------------- |
| `allowUnixSockets: string[]` | Allowlist of socket paths | _Ignored_ (seccomp can't filter by path) |
| `allowAllUnixSockets: boolean` | Allow all sockets | Disable seccomp blocking |
| Setting | macOS | Linux |
| ---------------------------------- | ------------------------- | ------------------------------------------------------ |
| `allowUnixSockets: string[]` | Allowlist of socket paths | _Ignored_ (seccomp can't filter by path) |
| `allowAllUnixSockets: boolean` | Allow all sockets | Disable seccomp blocking |
| `allowUnixSocketCreation: boolean` | _Ignored_ | Allow all sockets, keep the rest of the seccomp helper |

Unix sockets are **blocked by default** on both platforms.

- **macOS**: Use `allowUnixSockets` to allow specific paths (e.g., `["/var/run/docker.sock"]`), or `allowAllUnixSockets: true` to allow all.
- **Linux**: Blocking uses seccomp filters (x64/arm64 only). If seccomp isn't available, sockets are unrestricted and a warning is shown. Use `allowAllUnixSockets: true` to explicitly disable blocking.
- **Linux**: Blocking uses seccomp filters (x64/arm64 only). If seccomp isn't available, sockets are unrestricted and a warning is shown. Use `allowAllUnixSockets: true` to explicitly disable blocking. That removes the seccomp helper and everything else it does: VM sockets (`AF_VSOCK`) and io_uring become usable, and the PID namespace isolation, the namespaces filter and violation monitoring are gone. To allow Unix sockets and keep all of those, use `allowUnixSocketCreation: true` instead. Either way, every socket the command can reach in the filesystem becomes usable (see [Security Limitations](#security-limitations)).

#### Filesystem Configuration

Expand Down Expand Up @@ -1211,7 +1212,7 @@ On Linux, the sandbox uses **seccomp BPF (Berkeley Packet Filter)** to block Uni

**PID namespace isolation**: The nested PID namespace ensures the user command cannot see or address any process that runs without the seccomp filter (bwrap's init, the shell wrapper, or the socat helpers). This keeps the seccomp boundary intact regardless of `kernel.yama.ptrace_scope`, since unfiltered helpers are not reachable via `ptrace` or `/proc/N/mem`. The inner PID 1 sets `PR_SET_DUMPABLE=0` so it is not ptraceable either. If nested namespace creation fails, apply-seccomp aborts rather than running without isolation.

**Security limitations**: The filter blocks `socket(AF_UNIX, ...)` and the `io_uring_setup`/`io_uring_enter`/`io_uring_register` syscalls (the latter three because `IORING_OP_SOCKET` on Linux 5.19+ would otherwise bypass the `socket()` rule). It does not prevent operations on Unix socket file descriptors inherited from parent processes or passed via `SCM_RIGHTS`. For most sandboxing scenarios, blocking socket creation is sufficient to prevent unauthorized IPC.
**Security limitations**: The filter blocks `socket(AF_UNIX, ...)` and the `io_uring_setup`/`io_uring_enter`/`io_uring_register` syscalls (the latter three because `IORING_OP_SOCKET` on Linux 5.19+ would otherwise bypass the `socket()` rule). It does not prevent operations on Unix socket file descriptors inherited from parent processes or passed via `SCM_RIGHTS`. For most sandboxing scenarios, blocking socket creation is sufficient to prevent unauthorized IPC. Under `allowUnixSocketCreation` the helper applies the same filter without the `AF_UNIX` rule.

**Zero runtime dependencies**: Pre-built static apply-seccomp binaries and pre-generated BPF filters are included for x64 and arm64 architectures. No compilation tools or external dependencies required at runtime.

Expand Down
50 changes: 37 additions & 13 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export interface LinuxSandboxParams {
/** Lets the command undo the write denies: see {@link NESTED_USERNS_ENV}. */
allowNestedUserNamespaces?: boolean
allowAllUnixSockets?: boolean
/** Unix sockets under the helper: see {@link UNIX_SOCKETS_ENV}. */
allowUnixSocketCreation?: boolean
binShell?: string
ripgrepConfig?: RipgrepConfig
/** Maximum directory depth to search for dangerous files (default: 3) */
Expand Down Expand Up @@ -1676,13 +1678,27 @@ export async function initializeLinuxNetworkBridge(
const NESTED_USERNS_ENV = 'SRT_ALLOW_NESTED_USERNS'

/**
* The assignment the helper's command line starts with, so that the variable
* holds what the configuration says: the wrap reaches the helper through a
* shell, whose start-up files (BASH_ENV) could set it after bubblewrap cleared
* it, and an assignment before a command is made after those.
* Read by the seccomp helper, never by this process. `1` swaps its Unix-socket
* filter for one without the AF_UNIX rule, so the command can make Unix
* sockets while VM sockets and io_uring stay refused and the rest of what the
* helper does is kept. allowAllUnixSockets instead runs no helper at all.
*/
function helperEnvironmentPrefix(allowNestedUserNamespaces: boolean): string {
return `${NESTED_USERNS_ENV}=${allowNestedUserNamespaces ? '1' : '0'} `
const UNIX_SOCKETS_ENV = 'SRT_ALLOW_UNIX_SOCKETS'

/**
* The assignments the helper's command line starts with, so that the variables
* hold what the configuration says: the wrap reaches the helper through a
* shell, whose start-up files (BASH_ENV) could set them after bubblewrap
* cleared them, and an assignment before a command is made after those.
*/
function helperEnvironmentPrefix(
allowNestedUserNamespaces: boolean,
allowUnixSocketCreation: boolean,
): string {
return (
`${UNIX_SOCKETS_ENV}=${allowUnixSocketCreation ? '1' : '0'} ` +
`${NESTED_USERNS_ENV}=${allowNestedUserNamespaces ? '1' : '0'} `
)
}

// Keyed by path, like the uid-0 probe. Only an answer is kept: a probe that
Expand Down Expand Up @@ -3550,6 +3566,7 @@ export async function wrapCommandWithSandboxLinux(
enableWeakerNestedSandbox,
allowNestedUserNamespaces,
allowAllUnixSockets,
allowUnixSocketCreation,
binShell,
ripgrepConfig = { command: 'rg' },
mandatoryDenySearchDepth = DEFAULT_MANDATORY_DENY_SEARCH_DEPTH,
Expand Down Expand Up @@ -3614,7 +3631,8 @@ export async function wrapCommandWithSandboxLinux(
try {
// ========== SECCOMP FILTER (Unix Socket Blocking) ==========
// apply-seccomp wraps the workload and applies the baked-in BPF filter
// that blocks socket(AF_UNIX, ...). Skipped when allowAllUnixSockets is true.
// that blocks socket(AF_UNIX, ...). Skipped when allowAllUnixSockets is true;
// allowUnixSocketCreation keeps it, without the AF_UNIX rule.
if (!allowAllUnixSockets) {
applySeccompPrefix = resolveApplySeccompPrefix(
seccompConfig?.applyPath,
Expand All @@ -3629,10 +3647,14 @@ export async function wrapCommandWithSandboxLinux(
)
} else {
applySeccompPrefix =
helperEnvironmentPrefix(allowNestedUserNamespaces === true) +
applySeccompPrefix
helperEnvironmentPrefix(
allowNestedUserNamespaces === true,
allowUnixSocketCreation === true,
) + applySeccompPrefix
logForDebugging(
'[Sandbox Linux] Applying seccomp filter for Unix socket blocking',
allowUnixSocketCreation === true
? '[Sandbox Linux] Applying seccomp filter - Unix sockets allowed (allowUnixSocketCreation)'
: '[Sandbox Linux] Applying seccomp filter for Unix socket blocking',
)
}
} else {
Expand Down Expand Up @@ -3839,10 +3861,12 @@ export async function wrapCommandWithSandboxLinux(
bwrapArgs.push('--bind', '/proc', '/proc')
}

// The caller's environment decides nothing about the command's namespaces:
// the variable is cleared after every other environment operation, which
// bubblewrap applies in argument order, and helperEnvironmentPrefix sets it.
// The caller's environment decides nothing about the command's namespaces
// or Unix sockets: the variables are cleared after every other environment
// operation, which bubblewrap applies in argument order, and
// helperEnvironmentPrefix sets them.
bwrapArgs.push('--unsetenv', NESTED_USERNS_ENV)
bwrapArgs.push('--unsetenv', UNIX_SOCKETS_ENV)

// With no helper to refuse user namespaces, bubblewrap is asked to. Never
// beside the helper, which makes one of its own, and not under
Expand Down
11 changes: 11 additions & 0 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,17 @@ export const NetworkConfigSchema = z.object({
.describe(
'If true, allow all Unix sockets (disables blocking on both platforms). On Linux this also unblocks VM sockets (AF_VSOCK).',
),
allowUnixSocketCreation: z
.boolean()
.optional()
.describe(
'Linux only: let the sandboxed command create Unix sockets while the seccomp helper stays in ' +
'place, so that VM sockets (AF_VSOCK) and io_uring stay blocked and the PID namespace, the ' +
'namespaces filter and violation monitoring are kept. allowAllUnixSockets removes the helper ' +
'and all of these with it. As with allowAllUnixSockets, every Unix socket the command can ' +
'reach in the filesystem (Docker, the D-Bus session bus, ssh-agent) becomes usable. ' +
'Ignored when allowAllUnixSockets is true. Default: false.',
),
allowLocalBinding: z
.boolean()
.optional()
Expand Down
5 changes: 5 additions & 0 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,10 @@ function getAllowAllUnixSockets(): boolean | undefined {
return config?.network?.allowAllUnixSockets
}

function getAllowUnixSocketCreation(): boolean | undefined {
return config?.network?.allowUnixSocketCreation
}

function getAllowLocalBinding(): boolean | undefined {
return config?.network?.allowLocalBinding
}
Expand Down Expand Up @@ -2744,6 +2748,7 @@ async function wrapWithSandboxOnce(
enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
allowNestedUserNamespaces,
allowAllUnixSockets: getAllowAllUnixSockets(),
allowUnixSocketCreation: getAllowUnixSocketCreation(),
binShell,
ripgrepConfig: getRipgrepConfig(),
mandatoryDenySearchDepth: getMandatoryDenySearchDepth(),
Expand Down
21 changes: 21 additions & 0 deletions test/config-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@ describe('Config Validation', () => {
).toBe(false)
})

test('network.allowUnixSocketCreation is a boolean, and absent unless given', () => {
const filesystem = { denyRead: [], allowWrite: [], denyWrite: [] }
const network = { allowedDomains: [], deniedDomains: [] }
expect(
SandboxRuntimeConfigSchema.safeParse({ network, filesystem }).data
?.network.allowUnixSocketCreation,
).toBeUndefined()
expect(
SandboxRuntimeConfigSchema.safeParse({
network: { ...network, allowUnixSocketCreation: true },
filesystem,
}).data?.network.allowUnixSocketCreation,
).toBe(true)
expect(
SandboxRuntimeConfigSchema.safeParse({
network: { ...network, allowUnixSocketCreation: 'yes' },
filesystem,
}).success,
).toBe(false)
})

test('should reject missing required fields', () => {
const config = {
network: {
Expand Down
197 changes: 197 additions & 0 deletions test/sandbox/seccomp-unix-socket-creation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getApplySeccompBinaryPath } from '../../src/sandbox/generate-seccomp-filter.js'
import {
checkLinuxDependencies,
wrapCommandWithSandboxLinux,
type LinuxSandboxParams,
} from '../../src/sandbox/linux-sandbox-utils.js'
import { startLinuxSandboxViolationMonitor } from '../../src/sandbox/linux-violation-monitor.js'
import { SandboxManager } from '../../src/sandbox/sandbox-manager.js'
import { sanitizeUnregisteredCommandKey } from '../../src/sandbox/sandbox-violation-store.js'
import { quote } from '../../src/utils/shell-quote.js'
import { bwrapCanNamespaceNetwork } from '../helpers/bwrap-namespace.js'
import { isLinux } from '../helpers/platform.js'
import { spawnAsync } from '../helpers/spawn.js'

/**
* allowUnixSocketCreation keeps the seccomp helper and leaves only its AF_UNIX
* rule out. Each assertion is on the errno, as in seccomp-vsock-block.test.ts,
* and INIT names PID 1 of the command's PID namespace: the helper when it is
* in the chain, bubblewrap when it is not.
*/
const PROBE = [
'import ctypes, errno, os, socket',
'def attempt(label, family, kind):',
' try:',
' socket.socket(family, kind).close()',
' print(label + "=OK")',
' except OSError as e:',
' print(label + "=" + errno.errorcode.get(e.errno, str(e.errno)))',
'attempt("UNIX", socket.AF_UNIX, socket.SOCK_STREAM)',
'attempt("UNIX_DGRAM", socket.AF_UNIX, socket.SOCK_DGRAM)',
'attempt("VSOCK", socket.AF_VSOCK, socket.SOCK_STREAM)',
// io_uring_setup(4, &params): 425 on both x86_64 and aarch64.
'libc = ctypes.CDLL(None, use_errno=True)',
'params = ctypes.create_string_buffer(120)',
'fd = libc.syscall(ctypes.c_long(425), ctypes.c_uint(4), params)',
'print("IO_URING=" + ("OK" if fd >= 0 else errno.errorcode.get(ctypes.get_errno(), "?")))',
'print("ENV=" + os.environ.get("SRT_ALLOW_UNIX_SOCKETS", "unset"))',
'print("INIT=" + open("/proc/1/comm").read().strip())',
].join('\n')

const ALLOWED =
'UNIX=OK\nUNIX_DGRAM=OK\nVSOCK=EPERM\nIO_URING=EPERM\nENV=unset\n'
const REFUSED =
'UNIX=EPERM\nUNIX_DGRAM=EPERM\nVSOCK=EPERM\nIO_URING=EPERM\nENV=unset\n'

describe.if(isLinux)('apply-seccomp with SRT_ALLOW_UNIX_SOCKETS', () => {
let applySeccomp: string

const underHelper = (value: string | undefined) => {
const env = { ...process.env }
delete env.SRT_ALLOW_UNIX_SOCKETS
if (value !== undefined) env.SRT_ALLOW_UNIX_SOCKETS = value
return spawnSync(applySeccomp, ['python3', '-c', PROBE], {
encoding: 'utf8',
env,
timeout: 10000,
})
}

beforeAll(() => {
applySeccomp = getApplySeccompBinaryPath()!
expect(applySeccomp).toBeTruthy()
})

it('with 1, allows Unix sockets and keeps VM sockets and io_uring refused', () => {
const r = underHelper('1')
expect(r.stdout).toContain(ALLOWED)
expect(r.stdout).toContain('INIT=apply-seccomp')
expect(r.status).toBe(0)
})

it('with any other value, or none, refuses Unix sockets as before', () => {
for (const value of [undefined, '0', '', 'yes', 'true', ' 1']) {
const r = underHelper(value)
expect(r.stdout).toContain(REFUSED)
expect(r.status).toBe(0)
}
})
})

describe.if(isLinux)('allowUnixSocketCreation in the Linux sandbox', () => {
const wrap = (params: Partial<LinuxSandboxParams>) =>
wrapCommandWithSandboxLinux({
command: quote(['python3', '-c', PROBE]),
needsNetworkRestriction: false,
writeConfig: { allowOnly: ['/tmp'], denyWithinAllow: [] },
...params,
})

const run = (wrapped: string) =>
spawnAsync(wrapped, { shell: true, encoding: 'utf8', timeout: 15000 })

beforeAll(async () => {
expect(checkLinuxDependencies().errors).toEqual([])
await SandboxManager.reset()
})

afterAll(async () => {
await SandboxManager.reset()
})

it('tells the helper what the configuration says, whatever was inherited', async () => {
expect(await wrap({ allowUnixSocketCreation: true })).toContain(
'SRT_ALLOW_UNIX_SOCKETS=1 SRT_ALLOW_NESTED_USERNS=0 ',
)
expect(await wrap({})).toContain(
'SRT_ALLOW_UNIX_SOCKETS=0 SRT_ALLOW_NESTED_USERNS=0 ',
)
expect(await wrap({})).toContain('--unsetenv SRT_ALLOW_UNIX_SOCKETS')
})

it('lets the command make Unix sockets, with the helper still in the chain', async () => {
const r = await run(await wrap({ allowUnixSocketCreation: true }))
expect(r.stdout).toContain(ALLOWED)
expect(r.stdout).toContain('INIT=apply-seccomp')
expect(r.status).toBe(0)
})

it('without it, refuses them as before', async () => {
const r = await run(await wrap({}))
expect(r.stdout).toContain(REFUSED)
expect(r.stdout).toContain('INIT=apply-seccomp')
expect(r.status).toBe(0)
})

it('is ignored beside allowAllUnixSockets, which runs no helper', async () => {
const wrapped = await wrap({
allowAllUnixSockets: true,
allowUnixSocketCreation: true,
})
expect(wrapped).not.toContain('SRT_ALLOW_UNIX_SOCKETS=1')
const r = await run(wrapped)
expect(r.stdout).toContain('UNIX=OK')
expect(r.stdout).not.toContain('INIT=apply-seccomp')
expect(r.status).toBe(0)
})

it('still reports blocked writes', async () => {
const work = mkdtempSync(join(tmpdir(), 'srt-unix-'))
const allow = join(work, 'rw')
const deny = join(work, 'ro')
mkdirSync(allow)
mkdirSync(deny)
const lines: string[] = []
const monitor = startLinuxSandboxViolationMonitor(v => lines.push(v.line), {
allowWritePaths: [allow, '/dev'],
denyWritePaths: [deny],
resolveCommandText: sanitizeUnregisteredCommandKey,
})
await monitor.ready
try {
const wrapped = await wrap({
command: `echo a > ${quote([`${allow}/ok`])}; echo b > ${quote([`${deny}/bad`])}`,
writeConfig: { allowOnly: [allow], denyWithinAllow: [] },
allowUnixSocketCreation: true,
observeSocketPath: monitor.observeSocketPath,
})
await run(wrapped)
await new Promise(r => setTimeout(r, 100))
expect(lines).toContain(`deny openat ${deny}/bad`)
expect(lines.some(l => l.includes(`${allow}/ok`))).toBe(false)
} finally {
monitor.stop()
rmSync(work, { recursive: true, force: true })
}
})

it.skipIf(!bwrapCanNamespaceNetwork())(
'is read from the network configuration, in a network-restricted sandbox',
async () => {
await SandboxManager.initialize({
network: {
allowedDomains: [],
deniedDomains: [],
allowUnixSocketCreation: true,
},
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
})
const wrapped = await SandboxManager.wrapWithSandbox(
quote(['python3', '-c', PROBE]),
)
expect(wrapped).toContain('--unshare-net')
expect(wrapped).toContain('SRT_ALLOW_UNIX_SOCKETS=1 ')

const r = await run(wrapped)
expect(r.stdout).toContain(ALLOWED)
expect(r.stdout).toContain('INIT=apply-seccomp')
expect(r.status).toBe(0)
},
40000,
)
})
Loading