diff --git a/backend/src/__tests__/stack-file-roots-service.test.ts b/backend/src/__tests__/stack-file-roots-service.test.ts index d015bf21..4891201b 100644 --- a/backend/src/__tests__/stack-file-roots-service.test.ts +++ b/backend/src/__tests__/stack-file-roots-service.test.ts @@ -67,12 +67,18 @@ afterEach(async () => { describe('isDangerousHostPath', () => { it('flags the root and protected system directories and their descendants', () => { - for (const p of ['/', '/etc', '/etc/nginx', '/proc', '/sys/x', '/dev/sda', '/var/run', '/var/run/docker.sock', '/run/x']) { + for (const p of [ + '/', '/etc', '/etc/nginx', '/proc', '/sys/x', '/dev/sda', '/var/run', '/var/run/docker.sock', '/run/x', + // System locations holding the executables/libraries Sencho's runtime + // depends on: a bind here could overwrite a binary a deploy later runs. + '/usr', '/usr/local/bin', '/usr/local/bin/node', '/usr/bin', '/usr/lib', '/bin', '/bin/sh', + '/sbin', '/lib', '/lib64', '/boot', '/root', '/root/.ssh', + ]) { expect(isDangerousHostPath(p)).toBe(true); } }); - it('allows ordinary host paths', () => { - for (const p of ['/home/user/config', '/srv/app/data', 'C:\\data', '/etcetera']) { + it('allows ordinary host paths, including ones whose name only prefixes a protected root', () => { + for (const p of ['/home/user/config', '/srv/app/data', '/opt/app', '/mnt/data', 'C:\\data', '/etcetera', '/usrdata', '/libreoffice', '/booted']) { expect(isDangerousHostPath(p)).toBe(false); } }); @@ -154,6 +160,20 @@ describe('StackFileRootsService.listRoots', () => { expect(bind?.browsable).toBe(false); }); + it('blocks a bind into a system binary directory (/usr/local/bin) so Sencho binaries cannot be overwritten', async () => { + // A stack author with stack:edit could otherwise declare /usr/local/bin as a + // bind source, overwrite node/docker/the entrypoint, and have a later deploy + // execute it. The declared source is dangerous regardless of how realpath + // rewrites it (covered by the dangerousSource term), so this holds on any host. + stub({ rendered: renderModel({ web: [{ type: 'bind', source: '/usr/local/bin', target: '/host-bin', read_only: false }] }) }); + const roots = await StackFileRootsService.getInstance(1).listRoots(STACK, { fresh: true }); + const bind = roots.find((r) => r.kind === 'bind'); + expect(bind?.dangerous).toBe(true); + expect(bind?.browsable).toBe(false); + expect(bind?.writable).toBe(false); + expect(bind?.chmodable).toBe(false); + }); + it('blocks a dangerous declared source even when realpath rewrites it to a benign canonical', async () => { // Guards the dangerousSource term: realpath can rewrite a dangerous POSIX // source to a benign-looking canonical (a non-existent POSIX path resolves diff --git a/backend/src/services/StackFileRootsService.ts b/backend/src/services/StackFileRootsService.ts index 7c021903..22eb1bac 100644 --- a/backend/src/services/StackFileRootsService.ts +++ b/backend/src/services/StackFileRootsService.ts @@ -105,9 +105,20 @@ export function stackSourceFileRoot(hostPathOrName = ''): StackFileRoot { const ROOTS_CACHE_TTL_MS = 15_000; // Dangerous host directories: a bind equal to or under any of these grants -// node-level access and is never browsable. The docker socket is caught -// separately via isDockerSocketMount on the declared source. -const DANGEROUS_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/var/run', '/run']; +// node-level access and is never browsable. Two groups: +// - kernel/OS state: /etc, /proc, /sys, /dev, /var/run, /run. +// - the system locations holding the executables and libraries Sencho's own +// runtime depends on: /usr (which contains /usr/local/bin/{node,docker,npm} +// and /usr/local/lib), /bin, /sbin, /lib, /lib64 (the base-image binaries), +// plus /boot and /root. A stack author with stack:edit must not be able to +// declare one of these as a bind source, overwrite a binary, and have a +// later deploy execute it. +// The docker socket is caught separately via isDockerSocketMount on the +// declared source. +const DANGEROUS_ROOTS = [ + '/etc', '/proc', '/sys', '/dev', '/var/run', '/run', + '/usr', '/bin', '/sbin', '/lib', '/lib64', '/boot', '/root', +]; interface CacheEntry { roots: StackFileRoot[];