diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c201b6b12..3495363a0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -13,7 +13,7 @@ permissions: # certificate and of its public key. jobs: integration-tests: - name: Tests (${{ matrix.os }} / ${{ matrix.arch }}) + name: Tests (${{ matrix.os }} / ${{ matrix.label || matrix.arch }}) runs-on: ${{ matrix.runner }} strategy: @@ -32,6 +32,13 @@ jobs: - arch: arm64 runner: macos-15 os: macos + # The newest macOS as well: the sandbox profile's own regex engine + # and what Seatbelt refuses have both moved between releases, and + # the two legs above stay on the images they name. + - arch: arm64 + label: arm64-macos26 + runner: macos-26 + os: macos # Windows legs build srt-win, run the Rust smoke scripts, # then the TS integration suite (test/sandbox/winsrt.test.ts). - arch: x86-64 @@ -398,7 +405,36 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install -y bubblewrap libseccomp-dev gcc socat ripgrep apparmor-profiles zsh + sudo apt-get install -y bubblewrap libseccomp-dev gcc socat apparmor-profiles zsh + + # The library reads both the output and the exit status of ripgrep, and + # those differ between releases: for an unreadable directory exactly at + # --max-depth, 14.1.1 prints "Permission denied" and exits 2, where + # 15.2.0 passes over it and exits 0. 14.1.1 is what most installations + # have, so every later step finds it first on PATH; the second full run + # below puts 15.2.0 ahead of it. Both are upstream's archives, by the + # checksums upstream publishes. macOS runs whatever Homebrew serves. + - name: Install ripgrep 14.1.1 and 15.2.0 (Linux) + if: matrix.os == 'linux' + run: | + install_ripgrep() { + case "${{ matrix.arch }}" in + x86-64) asset=ripgrep-$1-x86_64-unknown-linux-musl.tar.gz sha256=$2 ;; + arm64) asset=ripgrep-$1-aarch64-unknown-linux-gnu.tar.gz sha256=$3 ;; + esac + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/$1/$asset" + echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict + mkdir "$RUNNER_TEMP/ripgrep-$1" + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-$1" --strip-components=1 + "$RUNNER_TEMP/ripgrep-$1/rg" --version + } + install_ripgrep 14.1.1 \ + 4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e \ + c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f + install_ripgrep 15.2.0 \ + 33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c \ + a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d + echo "$RUNNER_TEMP/ripgrep-14.1.1" >> "$GITHUB_PATH" - name: Enable unprivileged user namespaces (Linux) if: matrix.os == 'linux' @@ -424,18 +460,20 @@ jobs: - name: Install Node dependencies if: matrix.os != 'windows' - run: npm install + run: npm ci + # Builds dist/ as `npm run build` does, which compiles src only. This is + # the one step that checks the types of test/, which bun strips at runtime. + - name: Build and typecheck + if: matrix.os != 'windows' + run: npm run typecheck + + # After the type check, which builds dist/: test/smoke imports it, and + # without it the rules that read types see `any` there and say nothing. - name: Lint (ESLint) if: matrix.os == 'linux' && matrix.arch == 'x86-64' run: npx eslint . - # `npm run build` compiles src only. This is the one step that checks - # the types of test/, which bun strips at runtime. - - name: Typecheck - if: matrix.os != 'windows' - run: npm run typecheck - - name: Build seccomp binaries (Linux) if: matrix.os == 'linux' run: npm run build:seccomp @@ -444,10 +482,6 @@ jobs: if: matrix.os != 'windows' run: npm run build:java-agent - - name: Build project - if: matrix.os != 'windows' - run: npm run build - - name: Run tests if: matrix.os != 'windows' run: npm test @@ -470,9 +504,9 @@ jobs: ninja -C _build install "$RUNNER_TEMP/bwrap-0.12/bin/bwrap" --version - - name: Run tests against bubblewrap 0.12.0 (Linux) + - name: Run tests against bubblewrap 0.12.0 and ripgrep 15.2.0 (Linux) if: matrix.os == 'linux' - run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$PATH" npm test + run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$RUNNER_TEMP/ripgrep-15.2.0:$PATH" npm test # RHEL 8 and 9 ship bubblewrap 0.4.0 and 0.4.1, the oldest versions in # current distribution use, and both refuse to start on a plan that @@ -515,11 +549,23 @@ jobs: if: matrix.os != 'windows' run: node test/utils/which-node-test.mjs + # Everything above ran with Node 22 on PATH. What runs the library under + # Node repeats on 24, in a step so that the matrix stays as it is. + - name: Setup Node.js 24 + if: matrix.os != 'windows' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Run the tests that use Node (Node 24) + if: matrix.os != 'windows' + run: npm run test:node + - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results-${{ matrix.os }}-${{ matrix.arch }} + name: test-results-${{ matrix.os }}-${{ matrix.label || matrix.arch }} path: | test-results/ *.log @@ -679,6 +725,11 @@ jobs: with: node-version: '22' + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Enable unprivileged user namespaces on host run: | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true @@ -697,22 +748,22 @@ jobs: --security-opt apparmor=unconfined \ -v "${{ github.workspace }}:/work" \ -v "$(dirname "$(dirname "$(command -v node)")")":/opt/node:ro \ + -v "$(command -v bun)":/usr/local/bin/bun:ro \ -w /work \ -e SRT_E2E_DOCKER=1 \ ubuntu:24.04 \ bash -euo pipefail -c ' apt-get update -qq - # node and npm are those of the runner, bound in: no second - # piped installer, and not those of the image, which has Node - # 18, below what engines in package.json allows. bun is pinned - # to the version the rest of the matrix runs (oven-sh/setup-bun - # above). The tree carries a dozen bun-version-specific + # node, npm and bun are those of the runner, bound in: no piped + # installer, not the Node 18 of the image, which is below what + # engines in package.json allows, and the bun the rest of the + # matrix runs. The tree carries a dozen bun-version-specific # workarounds, so "whatever bun shipped this morning" is not # the runtime under test. - apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc libseccomp-dev libcap2-bin - curl -fsSL https://bun.sh/install | bash -s bun-v1.4.2 - export PATH="/opt/node/bin:$HOME/.bun/bin:$PATH" + apt-get install -y -qq bubblewrap socat ripgrep python3 ca-certificates gcc libseccomp-dev libcap2-bin + export PATH="/opt/node/bin:$PATH" node --version + bun --version npm ci npm run build:seccomp npm run build @@ -721,3 +772,35 @@ jobs: echo "== as uid 0 without CAP_SYS_ADMIN" capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" ' + + # The same two runs on musl, and the package as an embedder ships it. + # bubblewrap, socat, ripgrep and node are Alpine's own builds, and bun is + # its musl build, from the release archive by checksum. The rest is what + # the step above left in the workspace: node_modules and dist, which are + # JavaScript, and the seccomp helper, static against glibc, because that + # is the file the npm package brings to a musl host. + - name: Run srt end-to-end as uid 0 in an Alpine container + run: | + docker run --rm \ + --cap-add SYS_ADMIN \ + --security-opt seccomp=unconfined \ + --security-opt apparmor=unconfined \ + -v "${{ github.workspace }}:/work" \ + -w /work \ + -e SRT_E2E_DOCKER=1 \ + alpine:3.22 \ + sh -euc ' + apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip libcap-utils libstdc++ libgcc nodejs + case "$(uname -m)" in + x86_64) bun=bun-linux-x64-musl sha256=4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 ;; + aarch64) bun=bun-linux-aarch64-musl sha256=71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df ;; + esac + curl -fsSL --retry 3 -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/$bun.zip" + echo "$sha256 /tmp/bun.zip" | sha256sum -c - + unzip -q /tmp/bun.zip -d /tmp + install -m 0755 "/tmp/$bun/bun" /usr/local/bin/bun + echo "== as uid 0 with CAP_SYS_ADMIN" + bun test test/docker-weak-sandbox.test.ts test/smoke/bundled.test.ts + echo "== as uid 0 without CAP_SYS_ADMIN" + capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" + ' diff --git a/package-lock.json b/package-lock.json index 6b5676800..e5c04fa70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ }, "devDependencies": { "@eslint/js": "^9.14.0", - "@types/bun": "^1.3.2", - "@types/node": "^18", + "@types/bun": "^1.4.2", + "@types/node": "^22", "@types/node-forge": "^1.3.14", "eslint": "^9.14.0", "eslint-config-prettier": "^8.10.0", @@ -733,13 +733,13 @@ } }, "node_modules/@types/bun": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.2.tgz", - "integrity": "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.4.2.tgz", + "integrity": "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.2" + "bun-types": "1.4.2" } }, "node_modules/@types/estree": { @@ -764,13 +764,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-forge": { @@ -783,17 +783,6 @@ "@types/node": "*" } }, - "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", @@ -1606,16 +1595,13 @@ } }, "node_modules/bun-types": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.2.tgz", - "integrity": "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.4.2.tgz", + "integrity": "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" - }, - "peerDependencies": { - "@types/react": "^19" } }, "node_modules/call-bind": { @@ -1786,14 +1772,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", - "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5100,9 +5078,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 7cddf830b..625a68619 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "build:srt-proxy-node": "bun build src/proxy-main.ts --target=node --format=esm --outfile dist/srt-proxy.mjs", "clean": "rm -rf dist", "test": "bun test", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts test/utils/real-path.test.ts test/sandbox/default-write-paths.test.ts test/sandbox/rules-through-links-on-every-runtime.test.ts test/sandbox/tls-terminate-tunnel-ends.test.ts", + "typecheck": "tsc && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", "format": "prettier --write 'src/**/*.ts' --cache --log-level warn", @@ -38,8 +39,8 @@ }, "devDependencies": { "@eslint/js": "^9.14.0", - "@types/bun": "^1.3.2", - "@types/node": "^18", + "@types/bun": "^1.4.2", + "@types/node": "^22", "@types/node-forge": "^1.3.14", "eslint": "^9.14.0", "eslint-config-prettier": "^8.10.0", diff --git a/src/node-tls.d.ts b/src/node-tls.d.ts new file mode 100644 index 000000000..2839445da --- /dev/null +++ b/src/node-tls.d.ts @@ -0,0 +1,9 @@ +// The Bun type package redeclares `connect` in 'node:tls' in terms of names +// that @types/node 22 declares in 'tls' alone. Unresolved, they leave it +// taking and returning an untyped value. +declare module 'node:tls' { + import * as tls from 'tls' + export import ConnectionOptions = tls.ConnectionOptions + export import KeyObject = tls.KeyObject + export import TLSSocket = tls.TLSSocket +} diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index bc1f67aa6..e25ce25b2 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -526,8 +526,9 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { } } - // Handle CONNECT requests for HTTPS traffic - server.on('connect', async (req, socket, head) => { + // Handle CONNECT requests for HTTPS traffic. `head` is widened from the + // listener's NonSharedBuffer: it is reassigned below from socket reads. + server.on('connect', async (req, socket, head: Buffer) => { // Attach error handler immediately to prevent unhandled errors socket.on('error', err => { logForDebugging(`Client socket error: ${err.message}`, { level: 'error' }) diff --git a/src/sandbox/proxy-only.ts b/src/sandbox/proxy-only.ts index 7bcd6d488..2e64218dd 100644 --- a/src/sandbox/proxy-only.ts +++ b/src/sandbox/proxy-only.ts @@ -310,14 +310,15 @@ export function openFdStreams(fd: number): { input: Readable.fromWeb(file.stream()), output: new Writable({ write(chunk: Buffer, _encoding, callback) { - try { + // A failed write shows in one of three ways: write() rejects (a + // chunk too large to buffer), flush() throws, or flush() rejects. + // Inside an async function all three reach the callback. + const send = async (): Promise => { sink ??= file.writer() - sink.write(chunk) - // On a broken pipe flush() throws rather than rejects. - Promise.resolve(sink.flush()).then(() => callback(), callback) - } catch (err) { - callback(err as Error) + await sink.write(chunk) + await sink.flush() } + send().then(() => callback(), callback) }, final(callback) { Promise.resolve(sink?.end()).then(() => callback(), callback) diff --git a/test/docker-weak-sandbox.test.ts b/test/docker-weak-sandbox.test.ts index 2dbd49595..d28229184 100644 --- a/test/docker-weak-sandbox.test.ts +++ b/test/docker-weak-sandbox.test.ts @@ -5,7 +5,7 @@ * Gated on SRT_E2E_DOCKER so `npm test` on the host jobs skips it. CI runs it * twice in one container — once holding CAP_SYS_ADMIN, once under * `capsh --drop=cap_sys_admin` — because a uid-0 caller is what the - * capability drop exists for. + * capability drop exists for. Then the same in alpine:3.22, for musl. * * Invoked by CI via: * docker run --rm \ diff --git a/test/sandbox/proxy-only-edges.test.ts b/test/sandbox/proxy-only-edges.test.ts index f39102c93..1a94fd992 100644 --- a/test/sandbox/proxy-only-edges.test.ts +++ b/test/sandbox/proxy-only-edges.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, test } from 'bun:test' +import { closeSync, openSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' -import { startProxyOnly } from '../../src/sandbox/proxy-only.js' +import { openFdStreams, startProxyOnly } from '../../src/sandbox/proxy-only.js' import { SERVES_EMITTED_CONNECTIONS, testWithTls, } from '../helpers/emitted-connections.js' import { fakeDecider, type Frame } from '../helpers/fake-decider.js' +import { isLinux } from '../helpers/platform.js' const cleanup: Array<() => Promise | void> = [] afterEach(async () => { @@ -324,3 +326,21 @@ describe('TRACE inside a terminated tunnel', () => { }, ) }) + +describe('a write to an inherited fd that fails', () => { + // /dev/full refuses every write. Bun buffers the small chunk, so flush() + // reports it, and hands the large one straight to write(). + test.skipIf(!isLinux).each([1, 1 << 20])( + 'reaches the callback: %d byte(s)', + async size => { + const fd = openSync('/dev/full', 'w') + cleanup.push(() => closeSync(fd)) + const { output } = openFdStreams(fd) + output.on('error', () => {}) + const failure = await new Promise(resolve => + output.write(Buffer.alloc(size), resolve), + ) + expect(failure).toMatchObject({ code: 'ENOSPC' }) + }, + ) +}) diff --git a/test/smoke/bundled.test.ts b/test/smoke/bundled.test.ts new file mode 100644 index 000000000..3d78f3491 --- /dev/null +++ b/test/smoke/bundled.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Compiles embedder.ts, which says why, the ways an embedding application is + * built, and runs each result from an empty directory, so that nothing of the + * package is on disk around it. Not quite nothing: a bundle records where its + * modules were when it was built, so on the machine that built it a lookup + * from the library's own location still reaches this checkout. + */ +describe.skipIf(isWindows).each([ + { form: 'one file', flags: [] }, + // Modules are evaluated in another order, the library in a chunk of its own. + { form: 'split ESM chunks', flags: ['--splitting', '--format=esm'] }, +])('the built package, minified and as bytecode in $form', ({ flags }) => { + let home: string + + beforeAll(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), 'srt-embedder-'))) + const build = spawnSync( + process.execPath, + [ + ...['build', '--compile', '--minify', '--bytecode', ...flags], + join(import.meta.dir, 'embedder.ts'), + ...['--outfile', join(home, 'embedder')], + ], + { encoding: 'utf8' }, + ) + expect( + `status ${build.status}\n${build.stdout}${build.stderr}`, + ).toStartWith('status 0\n') + if (isLinux) { + copyFileSync( + join( + import.meta.dir, + '../../vendor/seccomp', + process.arch, + 'apply-seccomp', + ), + join(home, 'apply-seccomp'), + ) + } + }, 120_000) + + afterAll(() => rmSync(home, { recursive: true, force: true })) + + test.each([ + { how: 'told where the seccomp helper is', multicall: undefined }, + ...(isLinux + ? [ + { + how: 'reaching the helper and ripgrep through argv0', + multicall: '1', + }, + ] + : []), + ])( + 'wraps a command, $how', + ({ multicall }) => { + const run = spawnSync(join(home, 'embedder'), [], { + cwd: home, + env: { + ...process.env, + SRT_SMOKE_APPLY_SECCOMP: join(home, 'apply-seccomp'), + SRT_SMOKE_MULTICALL: multicall, + }, + encoding: 'utf8', + }) + expect(`${run.stdout}${run.stderr}${String(run.error ?? '')}`).toEndWith( + 'SMOKE OK\n', + ) + expect(run.status).toBe(0) + }, + 120_000, + ) +}) diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts new file mode 100644 index 000000000..147b6f507 --- /dev/null +++ b/test/smoke/embedder.ts @@ -0,0 +1,221 @@ +/** + * What an embedder ships, in miniature. bundled.test.ts compiles this and the + * BUILT package (dist/) into one minified executable of bytecode, where a + * function's name, its source text and the files beside it are all gone. It + * wraps a real command, checks what that did on the host, and checks that a + * refusal keeps the name and the code a caller branches on. The library comes + * through `import()`, so that a split bundle puts it in a chunk of its own. + * + * SRT_SMOKE_APPLY_SECCOMP names the seccomp helper. With SRT_SMOKE_MULTICALL=1 + * the helper and ripgrep are reached as by an embedder that carries both: by + * starting this executable under their names (`seccomp.argv0` with fd 3 open + * on it, and `ripgrep.argv0`). + */ +import { spawnSync } from 'node:child_process' +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + realpathSync, + rmSync, +} from 'node:fs' +import { once } from 'node:events' +import { createServer, type AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' + +// The multicall part. The library names the helper in ARGV0, since nothing +// between it and the exec inside the sandbox can set argv[0]. +const { ARGV0, ...env } = process.env +const name = ARGV0 === 'apply-seccomp' ? ARGV0 : basename(process.argv0) +if (name === 'apply-seccomp' || name === 'rg') { + appendFileSync(env.SRT_SMOKE_MULTICALL_LOG ?? '/dev/null', `${name}\n`) + const ran = spawnSync( + name === 'rg' ? name : (env.SRT_SMOKE_APPLY_SECCOMP ?? name), + process.argv.slice(2), + { stdio: 'inherit', env }, + ) + if (ran.error !== undefined) console.error(`${name}: ${String(ran.error)}`) + process.exit(ran.status ?? 127) +} + +function check(what: string, ok: boolean, detail = ''): void { + if (!ok) throw new Error(`FAILED: ${what}\n${detail}`) + console.log(`ok: ${what}`) +} + +// Bytecode is CommonJS, which has no top-level await. +async function main(): Promise { + const { SandboxManager, LinuxSandboxProfileError } = await import( + '../../dist/index.js' + ) + const multicall = process.env.SRT_SMOKE_MULTICALL === '1' + const applyPath = process.env.SRT_SMOKE_APPLY_SECCOMP + const root = realpathSync(mkdtempSync(join(tmpdir(), 'srt-bundled-smoke-'))) + const work = join(root, 'work') + const outside = join(root, 'outside') + mkdirSync(work) + mkdirSync(outside) + // Where a wrapped command can write: the helper is started inside. + const multicallLog = join(work, 'multicall.log') + if (multicall) process.env.SRT_SMOKE_MULTICALL_LOG = multicallLog + + const config = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [work], + denyWrite: [join(work, 'denied.txt')], + }, + // The container job says it is one the way it says it to its suite. + enableWeakerNestedSandbox: process.env.SRT_E2E_DOCKER === '1', + ...(multicall + ? { + seccomp: { applyPath: '/proc/self/fd/3', argv0: 'apply-seccomp' }, + ripgrep: { command: process.execPath, args: [], argv0: 'rg' }, + } + : { seccomp: { applyPath } }), + } + + try { + await SandboxManager.initialize(config) + + // After initialize, so that the configured helper is the one looked for. + const { warnings } = SandboxManager.checkDependencies() + check( + 'the dependency check finds the seccomp helper', + !warnings.some(w => w.includes('seccomp')), + warnings.join('\n'), + ) + + // A listener on the host, which the same words reach from outside the + // sandbox: without that, "blocked" could mean a shell that cannot connect. + const listener = createServer().listen(0, '127.0.0.1').unref() + await once(listener, 'listening') + const { port } = listener.address() as AddressInfo + const connect = `(exec 9<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED` + check( + 'the listener is reachable from outside the sandbox', + spawnSync('bash', ['-c', connect], { encoding: 'utf8' }).stdout === + 'NET-OPEN\n', + ) + + const wrapped = await SandboxManager.wrapWithSandbox( + [ + 'echo BOOTED', + 'echo allowed > allowed.txt', + 'echo denied > denied.txt 2>/dev/null || echo WRITE-DENIED', + 'echo outside > ../outside/x.txt 2>/dev/null || echo OUTSIDE-DENIED', + connect, + 'grep -q "^Seccomp:[[:space:]]*2" /proc/self/status 2>/dev/null && echo SECCOMP-ON || echo SECCOMP-OFF', + 'exit 7', + ].join('; '), + ) + check( + 'the seccomp helper it was told of is the one the command starts', + process.platform !== 'linux' || + multicall || + (applyPath !== undefined && wrapped.includes(applyPath)), + wrapped, + ) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: work, + timeout: 60000, + stdio: multicall + ? ['ignore', 'pipe', 'pipe', openSync(process.execPath, 'r')] + : 'pipe', + }) + const output = `${result.stdout}${result.stderr}` + check('the wrapped command started', output.includes('BOOTED'), output) + check( + 'a write inside the allowed directory lands', + existsSync(join(work, 'allowed.txt')) && + readFileSync(join(work, 'allowed.txt'), 'utf8') === 'allowed\n', + output, + ) + check( + 'a write to a denied path inside it does not', + output.includes('WRITE-DENIED') && + (!existsSync(join(work, 'denied.txt')) || + readFileSync(join(work, 'denied.txt'), 'utf8') === ''), + output, + ) + check( + 'a write outside it does not', + output.includes('OUTSIDE-DENIED') && !existsSync(join(outside, 'x.txt')), + output, + ) + check( + 'the network is closed', + output.includes('NET-BLOCKED') && !output.includes('NET-OPEN'), + output, + ) + check( + 'the command ran under the seccomp filter', + process.platform !== 'linux' || output.includes('SECCOMP-ON'), + output, + ) + for (const program of multicall ? ['rg', 'apply-seccomp'] : []) { + const started = readFileSync(multicallLog, 'utf8') + check( + `this executable was started as ${program}`, + started.split('\n').includes(program), + started, + ) + } + check( + "the command's own exit status comes back", + result.status === 7, + `status ${String(result.status)}\n${output}`, + ) + + SandboxManager.cleanupAfterCommand() + check( + 'the mount point for the denied path is gone after the command', + !existsSync(join(work, 'denied.txt')), + ) + check( + 'the configuration reads back', + SandboxManager.getConfig()?.filesystem.allowWrite.includes(work) === true, + ) + + if (process.platform === 'linux') { + // More absent deny paths than bubblewrap takes arguments for: the wrap + // is refused before anything is spawned or written. + SandboxManager.updateConfig({ + ...config, + filesystem: { + ...config.filesystem, + denyWrite: Array.from({ length: 4000 }, (_, i) => + join(work, `absent-${i}`), + ), + }, + }) + const refusal: unknown = await SandboxManager.wrapWithSandbox( + 'true', + ).catch((error: unknown) => error) + check( + 'a profile that does not fit is refused with an error that keeps its type, name and code', + refusal instanceof LinuxSandboxProfileError && + refusal.name === 'LinuxSandboxProfileError' && + refusal.code === 'too_many_arguments', + String(refusal), + ) + } + console.log('SMOKE OK') + } finally { + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) + } +} + +main().catch((error: unknown) => { + // Not the error itself: the runtime would print the minified source with it. + console.error(error instanceof Error ? error.stack : error) + process.exit(1) +})