diff --git a/backend/src/__tests__/filesystem-symlink-escape.test.ts b/backend/src/__tests__/filesystem-symlink-escape.test.ts new file mode 100644 index 00000000..ecdf219c --- /dev/null +++ b/backend/src/__tests__/filesystem-symlink-escape.test.ts @@ -0,0 +1,332 @@ +/** + * Symlink/junction escape hardening for FileSystemService's legacy managed-stack + * methods. A stack directory (or a managed .env / compose.yaml leaf) that is a + * symlink pointing outside the compose root must be rejected before the + * read/write/delete sink follows the link out of tree. The lexical inline + * barriers cannot see symlinks; assertRealWithinBase realpaths both sides. + * + * Real symlink creation needs admin or Developer Mode on Windows, so the escape + * cases run on Linux/macOS (CI) and skip on the Windows dev box, mirroring + * filesystem-stack-paths.test.ts. The happy-path block at the bottom runs on + * every platform and confirms the guard is a no-op on normal directories. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +const isWindows = process.platform === 'win32'; + +// Mutable state the mocked NodeRegistry reads; beforeEach rewrites it before any +// FileSystemService is instantiated (the constructor reads composeDir eagerly). +const mockState = { composeDir: '' }; + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getComposeDir: () => mockState.composeDir, + getDefaultNodeId: () => 1, + }), + }, +})); + +vi.mock('../utils/debug', () => ({ isDebugEnabled: () => false })); + +import { FileSystemService } from '../services/FileSystemService'; + +const STACK = 'mystack'; + +// ── Whole stack directory is a junction pointing out of the compose root ────── +describe.skipIf(isWindows)('FileSystemService symlink-escape: symlinked stack dir', () => { + let tmpBase: string; + let composeDir: string; + let externalTarget: string; + let dataDir: string; + let originalDataDir: string | undefined; + + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symesc-')); + composeDir = path.join(tmpBase, 'compose'); + externalTarget = path.join(tmpBase, 'external'); + await fs.mkdir(composeDir, { recursive: true }); + await fs.mkdir(externalTarget, { recursive: true }); + // Plant sentinel managed files in the out-of-tree target. + await fs.writeFile(path.join(externalTarget, '.env'), 'SECRET=leaked\n', 'utf-8'); + await fs.writeFile(path.join(externalTarget, 'compose.yaml'), 'services: {}\n', 'utf-8'); + mockState.composeDir = composeDir; + // The stack directory itself is a junction out of the compose root. + await fs.symlink(externalTarget, path.join(composeDir, STACK)); + // DATA_DIR backs the backup/restore/snapshot slot. + dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symesc-data-')); + originalDataDir = process.env.DATA_DIR; + process.env.DATA_DIR = dataDir; + }); + + afterEach(async () => { + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + await fs.rm(tmpBase, { recursive: true, force: true }); + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + it('saveEnvContent rejects and leaves the out-of-tree .env untouched', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.saveEnvContent(STACK, 'PWNED=1\n')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + expect(await fs.readFile(path.join(externalTarget, '.env'), 'utf-8')).toBe('SECRET=leaked\n'); + }); + + it('saveStackContent rejects and leaves the out-of-tree compose.yaml untouched', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.saveStackContent(STACK, 'services: { evil: {} }\n')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + expect(await fs.readFile(path.join(externalTarget, 'compose.yaml'), 'utf-8')).toBe('services: {}\n'); + }); + + it('saveStackContentIfUnchanged rejects', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.saveStackContentIfUnchanged(STACK, 'services: {}\n', null)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + + it('deleteStack rejects and the out-of-tree directory survives', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.deleteStack(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(fs.access(externalTarget)).resolves.toBeUndefined(); + expect(await fs.readFile(path.join(externalTarget, '.env'), 'utf-8')).toBe('SECRET=leaked\n'); + }); + + it('getEnvContent rejects instead of returning the planted secret', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.getEnvContent(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + + it('envExists degrades to false without throwing', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.envExists(STACK)).resolves.toBe(false); + }); + + it('backupStackFiles rejects and does not copy the out-of-tree file into the backup slot', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.backupStackFiles(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(fs.access(path.join(dataDir, 'backups', '1', STACK, '.env'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('restoreStackFiles rejects before reading the backup slot', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.restoreStackFiles(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + + it('snapshotStackFiles rejects and never captures the out-of-tree contents', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.snapshotStackFiles(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + + it('getStackContentWithMtime rejects via the guarded getComposeFilePath', async () => { + await fs.writeFile(path.join(externalTarget, 'compose.yaml'), 'services: { evil: {} }\n', 'utf-8'); + const svc = FileSystemService.getInstance(); + await expect(svc.getStackContentWithMtime(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + + it('generic readFile/writeFile/access through the symlinked stack dir reject', async () => { + const svc = FileSystemService.getInstance(); + const envAbs = path.join(composeDir, STACK, '.env'); + await expect(svc.readFile(envAbs)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(svc.access(envAbs)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(svc.writeFile(envAbs, 'PWNED=1\n')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + expect(await fs.readFile(path.join(externalTarget, '.env'), 'utf-8')).toBe('SECRET=leaked\n'); + }); + + it('writeFileIfUnchanged/statMtime through the symlinked stack dir reject (env PUT route sinks)', async () => { + const svc = FileSystemService.getInstance(); + const envAbs = path.join(composeDir, STACK, '.env'); + await expect(svc.writeFileIfUnchanged(envAbs, 'PWNED=1\n', null)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(svc.statMtime(envAbs)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + expect(await fs.readFile(path.join(externalTarget, '.env'), 'utf-8')).toBe('SECRET=leaked\n'); + }); +}); + +// ── A managed .env leaf is a symlink out of tree (stack dir is real) ────────── +describe.skipIf(isWindows)('FileSystemService symlink-escape: symlinked managed-file leaf', () => { + let tmpBase: string; + let composeDir: string; + let stackDir: string; + let externalEnv: string; + + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symleaf-')); + composeDir = path.join(tmpBase, 'compose'); + stackDir = path.join(composeDir, STACK); + await fs.mkdir(stackDir, { recursive: true }); + externalEnv = path.join(tmpBase, 'outside.env'); + await fs.writeFile(externalEnv, 'SECRET=leaked\n', 'utf-8'); + mockState.composeDir = composeDir; + // The .env inside an otherwise-normal stack dir links out of tree. + await fs.symlink(externalEnv, path.join(stackDir, '.env')); + }); + + afterEach(async () => { + await fs.rm(tmpBase, { recursive: true, force: true }); + }); + + it('saveEnvContent rejects and leaves the linked target untouched', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.saveEnvContent(STACK, 'PWNED=1\n')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + expect(await fs.readFile(externalEnv, 'utf-8')).toBe('SECRET=leaked\n'); + }); + + it('getEnvContent rejects instead of returning the linked secret', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.getEnvContent(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); +}); + +// ── Dangling (broken) symlinks: a link whose target does not exist must still ─ +// be rejected, because a write/mkdir would follow it out of tree. +describe.skipIf(isWindows)('FileSystemService symlink-escape: dangling (broken) symlink', () => { + let tmpBase: string; + let composeDir: string; + let stackDir: string; + + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symdangle-')); + composeDir = path.join(tmpBase, 'compose'); + stackDir = path.join(composeDir, STACK); + await fs.mkdir(stackDir, { recursive: true }); + mockState.composeDir = composeDir; + }); + + afterEach(async () => { + await fs.rm(tmpBase, { recursive: true, force: true }); + }); + + it('rejects a dangling .env leaf (link to a non-existent target)', async () => { + await fs.symlink(path.join(tmpBase, 'does-not-exist.env'), path.join(stackDir, '.env')); + const svc = FileSystemService.getInstance(); + await expect(svc.saveEnvContent(STACK, 'PWNED=1\n')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(svc.getEnvContent(STACK)).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); + + it('rejects a dangling stack-dir junction (link to a non-existent target)', async () => { + await fs.symlink(path.join(tmpBase, 'no-such-dir'), path.join(composeDir, 'ghost')); + const svc = FileSystemService.getInstance(); + await expect(svc.createStack('ghost')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + await expect(svc.saveEnvContent('ghost', 'X=1\n')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' }); + }); +}); + +// ── The compose root itself is a symlink: must NOT be a false positive ─────── +describe.skipIf(isWindows)('FileSystemService symlink-escape: symlinked compose root is allowed', () => { + let tmpBase: string; + let realBase: string; + let linkBase: string; + + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symbase-')); + realBase = path.join(tmpBase, 'realbase'); + linkBase = path.join(tmpBase, 'linkbase'); + await fs.mkdir(path.join(realBase, STACK), { recursive: true }); + await fs.writeFile(path.join(realBase, STACK, '.env'), 'OK=1\n', 'utf-8'); + await fs.writeFile(path.join(realBase, STACK, 'compose.yaml'), 'services: {}\n', 'utf-8'); + // COMPOSE_DIR is a symlink to the real base; both canonicalize alike. + await fs.symlink(realBase, linkBase); + mockState.composeDir = linkBase; + }); + + afterEach(async () => { + await fs.rm(tmpBase, { recursive: true, force: true }); + }); + + it('getEnvContent reads through the symlinked root', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.getEnvContent(STACK)).resolves.toBe('OK=1\n'); + }); + + it('saveEnvContent writes through the symlinked root', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.saveEnvContent(STACK, 'OK=2\n')).resolves.toBeUndefined(); + expect(await fs.readFile(path.join(realBase, STACK, '.env'), 'utf-8')).toBe('OK=2\n'); + }); + + it('createStack succeeds under the symlinked root', async () => { + const svc = FileSystemService.getInstance(); + await expect(svc.createStack('fresh')).resolves.toBeUndefined(); + expect(await fs.readFile(path.join(realBase, 'fresh', 'compose.yaml'), 'utf-8')).toContain('services:'); + }); +}); + +// ── migrateFlatToDirectory skips an escaping entry rather than aborting all ── +describe.skipIf(isWindows)('FileSystemService symlink-escape: migrate skips an escaping entry', () => { + let tmpBase: string; + let composeDir: string; + let externalTarget: string; + + beforeEach(async () => { + tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symmig-')); + composeDir = path.join(tmpBase, 'compose'); + externalTarget = path.join(tmpBase, 'external'); + await fs.mkdir(composeDir, { recursive: true }); + await fs.mkdir(externalTarget, { recursive: true }); + mockState.composeDir = composeDir; + }); + + afterEach(async () => { + await fs.rm(tmpBase, { recursive: true, force: true }); + }); + + it('skips a flat entry whose target dir is a symlink out of tree, migrates the rest', async () => { + await fs.writeFile(path.join(composeDir, 'app.yaml'), 'services: {}\n', 'utf-8'); + await fs.writeFile(path.join(composeDir, 'evil.yaml'), 'services: {}\n', 'utf-8'); + await fs.symlink(externalTarget, path.join(composeDir, 'evil')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const svc = FileSystemService.getInstance(); + await expect(svc.migrateFlatToDirectory()).resolves.toBeUndefined(); + + // The legitimate stack migrated. + expect(await fs.readFile(path.join(composeDir, 'app', 'compose.yaml'), 'utf-8')).toBe('services: {}\n'); + // The escaping entry was skipped: nothing was written through the symlink. + await expect(fs.access(path.join(externalTarget, 'compose.yaml'))).rejects.toMatchObject({ code: 'ENOENT' }); + warn.mockRestore(); + }); +}); + +// ── Happy path on every platform: the guard is a no-op on normal directories ── +describe('FileSystemService symlink-escape: happy path (no symlinks)', () => { + let composeDir: string; + + beforeEach(async () => { + composeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-symok-')); + mockState.composeDir = composeDir; + }); + + afterEach(async () => { + await fs.rm(composeDir, { recursive: true, force: true }); + }); + + it('createStack then saveEnvContent / saveStackContent / deleteStack round-trip', async () => { + const svc = FileSystemService.getInstance(); + await svc.createStack(STACK); + expect(await fs.readFile(path.join(composeDir, STACK, 'compose.yaml'), 'utf-8')).toContain('services:'); + + await svc.saveStackContent(STACK, 'services: { web: {} }\n'); + expect(await fs.readFile(path.join(composeDir, STACK, 'compose.yaml'), 'utf-8')).toBe('services: { web: {} }\n'); + + const read = await svc.getStackContentWithMtime(STACK); + expect(read.content).toBe('services: { web: {} }\n'); + + await svc.saveEnvContent(STACK, 'FOO=bar\n'); + expect(await fs.readFile(path.join(composeDir, STACK, '.env'), 'utf-8')).toBe('FOO=bar\n'); + + await svc.deleteStack(STACK); + await expect(fs.access(path.join(composeDir, STACK))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('migrateFlatToDirectory moves a flat compose + env into a stack directory', async () => { + await fs.writeFile(path.join(composeDir, 'web.yaml'), 'services: {}\n', 'utf-8'); + await fs.writeFile(path.join(composeDir, 'web.env'), 'FOO=bar\n', 'utf-8'); + + const svc = FileSystemService.getInstance(); + await expect(svc.migrateFlatToDirectory()).resolves.toBeUndefined(); + + expect(await fs.readFile(path.join(composeDir, 'web', 'compose.yaml'), 'utf-8')).toBe('services: {}\n'); + expect(await fs.readFile(path.join(composeDir, 'web', '.env'), 'utf-8')).toBe('FOO=bar\n'); + }); +}); diff --git a/backend/src/__tests__/filesystem.test.ts b/backend/src/__tests__/filesystem.test.ts index 123b08f6..3fd46ce0 100644 --- a/backend/src/__tests__/filesystem.test.ts +++ b/backend/src/__tests__/filesystem.test.ts @@ -27,6 +27,12 @@ vi.mock('fs', () => ({ rename: vi.fn(), copyFile: vi.fn(), unlink: vi.fn(), + // deleteStack now realpath-checks the stack dir against the compose root + // before rm. Resolve to the absolute path (no symlink) so the containment + // guard passes and these tests still exercise the rm error translation; + // the guard's symlink-escape behaviour is covered in + // filesystem-symlink-escape.test.ts. + realpath: vi.fn((p: string) => Promise.resolve(path.resolve(p))), }, })); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 625dbcf1..be86cd5b 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -202,6 +202,7 @@ export class FileSystemService { private async getComposeFilePath(stackName: string): Promise { const stackDir = this.resolveStackDir(stackName); + await this.assertRealWithinBase(stackDir); const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; for (const file of composeFiles) { const filePath = path.join(stackDir, file); @@ -286,6 +287,7 @@ export class FileSystemService { async saveStackContent(stackName: string, content: string): Promise { const stackDir = this.resolveStackDir(stackName); const filePath = path.join(stackDir, 'compose.yaml'); + await this.assertRealWithinBase(filePath); try { await fsPromises.writeFile(filePath, content, 'utf-8'); } catch (error) { @@ -321,6 +323,7 @@ export class FileSystemService { if (!safePath.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); } + await this.assertRealWithinBase(safePath); if (expectedMtimeMs !== null) { let fh: import('fs/promises').FileHandle | null = null; @@ -363,6 +366,7 @@ export class FileSystemService { if (!safePath.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); } + await this.assertRealWithinBase(safePath); if (expectedMtimeMs !== null) { let fh: import('fs/promises').FileHandle | null = null; @@ -391,6 +395,7 @@ export class FileSystemService { if (!safePath.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); } + await this.assertRealWithinBase(safePath); try { const stat = await fsPromises.stat(safePath); return stat.mtimeMs; @@ -412,6 +417,7 @@ export class FileSystemService { return false; } try { + await this.assertRealWithinBase(target); await fsPromises.access(target); return true; } catch { @@ -421,16 +427,19 @@ export class FileSystemService { async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise { this.assertWithinBase(filePath); + await this.assertRealWithinBase(filePath); return fsPromises.readFile(filePath, encoding); } async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise { this.assertWithinBase(filePath); + await this.assertRealWithinBase(filePath); return fsPromises.writeFile(filePath, content, encoding); } async access(filePath: string): Promise { this.assertWithinBase(filePath); + await this.assertRealWithinBase(filePath); return fsPromises.access(filePath); } @@ -440,6 +449,7 @@ export class FileSystemService { if (!isPathWithinBase(envPath, base)) { throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); } + await this.assertRealWithinBase(envPath); try { return await fsPromises.readFile(envPath, 'utf-8'); } catch (error) { @@ -453,6 +463,7 @@ export class FileSystemService { async saveEnvContent(stackName: string, content: string): Promise { const stackDir = this.resolveStackDir(stackName); const envPath = path.join(stackDir, '.env'); + await this.assertRealWithinBase(envPath); try { await fsPromises.writeFile(envPath, content, 'utf-8'); } catch (error) { @@ -463,6 +474,7 @@ export class FileSystemService { async createStack(stackName: string): Promise { const stackDir = this.resolveStackDir(stackName); + await this.assertRealWithinBase(stackDir); try { await fsPromises.access(stackDir); @@ -491,6 +503,7 @@ export class FileSystemService { public async deleteStack(stackName: string): Promise { const stackDir = this.resolveStackDir(stackName); + await this.assertRealWithinBase(stackDir); try { await fsPromises.rm(stackDir, { recursive: true, force: true }); } catch (error: unknown) { @@ -736,6 +749,82 @@ export class FileSystemService { return real; } + /** + * Reject when `targetPath` (an absolute managed stack dir, or a managed file + * inside it) would let an operation escape the real compose root via a + * symlink/junction. Complements the lexical inline barrier at each sink, which + * cannot see symlinks: path.resolve does not follow links. + * + * Walks up to the deepest path component that actually exists and confirms its + * canonical (realpath'd) location is inside the canonical compose root. The + * base is realpath'd too, so a legitimately symlinked compose root is not a + * false positive (both canonicalize through the same root link). Two escape + * shapes are rejected: an existing path that resolves outside the root, and a + * dangling symlink (a link whose target does not exist) anywhere on the path, + * since a write/mkdir would follow it out of tree. Components that are simply + * absent are safe (they get created as real entries), so they are walked past. + * + * No-op when the compose root itself does not exist yet (first-run + * create/migrate): nothing can exist under it, so no link can be followed. + * + * `targetPath` must be absolute; realpath of a relative path would resolve + * against the process cwd. + */ + private async assertRealWithinBase(targetPath: string): Promise { + // Canonical js/path-injection barrier (mirrors every other sink in this + // file): resolve the untrusted target against the compose root and confirm + // lexical containment before any filesystem probe, so static analysis + // credits the sanitizer for the realpath/lstat calls below. Callers already + // build targetPath under the base, so this never rejects a legitimate or a + // symlink-escaping path (both are lexically contained); the realpath walk + // below is what actually catches symlink/junction escapes. + const baseResolved = path.resolve(this.baseDir); + const safeTarget = path.resolve(baseResolved, targetPath); + if (!safeTarget.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + + let realBase: string; + try { + realBase = await fsPromises.realpath(this.baseDir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + return; + } + + const escape = () => + Object.assign(new Error('Path escapes compose directory via symlink'), { code: 'SYMLINK_ESCAPE' }); + + let cursor = safeTarget; + for (;;) { + let realCursor: string; + try { + realCursor = await fsPromises.realpath(cursor); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + // cursor did not resolve. A dangling symlink still lstat's (the link + // exists); a genuinely absent component does not. Reject the dangling + // link; walk up past an absent component to the nearest real ancestor. + let danglingLink = false; + try { + await fsPromises.lstat(cursor); + danglingLink = true; + } catch (le) { + if ((le as NodeJS.ErrnoException).code !== 'ENOENT') throw le; + } + if (danglingLink) throw escape(); + const parent = path.dirname(cursor); + if (parent === cursor) throw escape(); + cursor = parent; + continue; + } + if (realCursor !== realBase && !realCursor.startsWith(realBase + path.sep)) { + throw escape(); + } + return; + } + } + async migrateFlatToDirectory(): Promise { try { try { @@ -753,6 +842,15 @@ export class FileSystemService { const stackName = item.name.replace(/\.(yml|yaml)$/, ''); const stackDir = path.join(this.baseDir, stackName); + try { + await this.assertRealWithinBase(stackDir); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'SYMLINK_ESCAPE') throw e; + // A symlinked entry escaping the compose root is hostile/anomalous; + // skip just it so the remaining flat stacks still migrate. + console.warn(`[FileSystemService] Skipping migration of ${stackName}: stack path escapes the compose directory`); + continue; + } try { await fsPromises.access(stackDir); @@ -798,6 +896,7 @@ export class FileSystemService { const debug = isDebugEnabled(); const t0 = Date.now(); const stackDir = this.resolveStackDir(stackName); + await this.assertRealWithinBase(stackDir); // Canonical js/path-injection barrier (mirrors restoreStackFiles): resolve the // backup path against the backup root and confirm containment inline, so the // mkdir/copy/write sinks below operate on a validated path. stackName is @@ -866,6 +965,7 @@ export class FileSystemService { const debug = isDebugEnabled(); const t0 = Date.now(); const stackDir = this.resolveStackDir(stackName); + await this.assertRealWithinBase(stackDir); // Canonical js/path-injection barrier at the backup read sink: resolve the // backup dir against its root and confirm containment inline, mirroring // backupStackFiles. stackName is already validated by resolveStackDir above; @@ -936,6 +1036,7 @@ export class FileSystemService { */ async snapshotStackFiles(stackName: string): Promise<() => Promise> { const stackDir = this.resolveStackDir(stackName); + await this.assertRealWithinBase(stackDir); // Canonical js/path-injection barrier inline with the read/write sinks, the // same pattern restoreStackFiles uses: resolve against the base and confirm // containment so static analysis credits the barrier.