mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(stacks): harden stack file path containment against symlink escapes (#1415)
* fix(stacks): harden stack file path containment against symlink escapes The legacy managed-stack methods enforced path containment lexically (path.resolve + startsWith), which does not follow symlinks. A stack directory under the compose root that is itself a symlink or junction could let a managed-file operation (write compose.yaml/.env, delete a stack, backup, restore, snapshot) follow the link and read, write, or delete a file of the same name outside the compose root. Add a realpath-based containment guard that walks up to the deepest existing path component, confirms its canonical location is inside the canonical compose root, and rejects both an out-of-tree resolution and a dangling symlink (which a write or mkdir would still follow). The guard runs at every legacy managed-stack sink, alongside the existing lexical barriers. A legitimately symlinked compose root is not a false positive because both sides are canonicalized through the same link, and the guard is a no-op for not-yet-created targets so stack creation and the flat-to-directory migration are unaffected. * fix(stacks): satisfy the path-injection sanitizer in the symlink containment guard assertRealWithinBase resolves a user-derived path and probes it with realpath/lstat to run the containment check, which static analysis flags as path injection because the probes lacked the inline barrier its sanitizer recognizes. Add the canonical path.resolve + startsWith barrier at the top of the helper, the same form every other sink in this file uses, and seed the realpath walk from the sanitized value. Behavior is unchanged: callers always pass an absolute, already-contained path, so the barrier is a no-op pre-check for them, and the realpath walk still catches the symlink and dangling-link escapes.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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))),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ export class FileSystemService {
|
||||
|
||||
private async getComposeFilePath(stackName: string): Promise<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
this.assertWithinBase(filePath);
|
||||
await this.assertRealWithinBase(filePath);
|
||||
return fsPromises.readFile(filePath, encoding);
|
||||
}
|
||||
|
||||
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
|
||||
this.assertWithinBase(filePath);
|
||||
await this.assertRealWithinBase(filePath);
|
||||
return fsPromises.writeFile(filePath, content, encoding);
|
||||
}
|
||||
|
||||
async access(filePath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void>> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user