fix: contain file-explorer binds into Sencho's system directories (#1484)

The file-explorer root containment treated only kernel and OS-state paths
(/etc, /proc, /sys, /dev, /run, /var/run) as dangerous. System locations
that hold the executables and libraries Sencho's own runtime depends on,
notably /usr (which contains /usr/local/bin/node, the docker CLI, and the
entrypoint) plus /bin, /sbin, /lib, /lib64, /boot and /root, were left
browsable, writable and chmodable.

A stack author with stack:edit could declare one of these as a bind source,
overwrite a binary, and have a later deploy execute it. Add those locations
to the dangerous-root set so such a bind is never browsable or editable; the
boundary check still permits ordinary host paths whose name merely prefixes a
protected root (for example /usrdata).
This commit is contained in:
Anso
2026-06-26 22:39:33 -04:00
committed by GitHub
parent 5960c1e85e
commit 73f4bc27c3
2 changed files with 37 additions and 6 deletions
@@ -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