moving
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* Tests the /api/workspaces/:workspaceId/files/* endpoints
|
||||
* for virtual storage mode (filesystem-backed).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
@@ -92,6 +92,19 @@ describe('File Management API', () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('Invalid path');
|
||||
});
|
||||
|
||||
it('rejects an encoded workspace-id escape before building a virtual root', async () => {
|
||||
const escapedRoot = path.join(tmpDir, 'outside', 'files');
|
||||
expect(fs.existsSync(escapedRoot)).toBe(false);
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/workspaces/..%5Coutside/files/list?path=/',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fs.existsSync(escapedRoot)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Upload ───────────────────────────────────────────────────
|
||||
@@ -443,6 +456,97 @@ describe('File Management API', () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local workspace filesystem boundary', () => {
|
||||
it.each(['PUT', 'PATCH'] as const)(
|
||||
'%s rejects storage-root rebinding before the files API can read outside data',
|
||||
async (method) => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-files-rebind-outside-'));
|
||||
fs.writeFileSync(path.join(outside, 'sentinel.txt'), 'outside-secret');
|
||||
|
||||
try {
|
||||
const created = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: `Rebind ${method}`, group: 'Test' },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const rebindWorkspaceId = created.json().id as string;
|
||||
|
||||
const update = await injectWithAuth(server, {
|
||||
method,
|
||||
url: `/api/workspaces/${rebindWorkspaceId}`,
|
||||
payload: { storageType: 'local', storagePath: outside },
|
||||
});
|
||||
|
||||
expect(update.statusCode).toBe(400);
|
||||
expect(server.workspaceManager.get(rebindWorkspaceId)?.storageType).toBeUndefined();
|
||||
expect(server.workspaceManager.get(rebindWorkspaceId)?.storagePath).toBeUndefined();
|
||||
|
||||
const sentinel = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${rebindWorkspaceId}/files/download?path=/sentinel.txt`,
|
||||
});
|
||||
expect(sentinel.statusCode).toBe(404);
|
||||
expect(sentinel.body).not.toContain('outside-secret');
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('blocks sensitive files and escaping junctions through the real API', async () => {
|
||||
const linkedRoot = path.join(tmpDir, 'linked-security-root');
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-files-api-outside-'));
|
||||
fs.mkdirSync(linkedRoot);
|
||||
fs.writeFileSync(path.join(linkedRoot, '.env'), 'TOKEN=secret');
|
||||
fs.writeFileSync(path.join(outside, 'secret.txt'), 'outside');
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(linkedRoot, 'escape'), 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Linked Security Workspace', group: 'Test', storageType: 'local', storagePath: linkedRoot },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const linkedWorkspaceId = created.json().id as string;
|
||||
// Persisted local workspaces can come from prior releases (and the
|
||||
// separate workspace-update finding); exercise the real files route.
|
||||
server.workspaceManager.update(linkedWorkspaceId, { storageType: 'local', storagePath: linkedRoot });
|
||||
const localPrefix = `/api/workspaces/${linkedWorkspaceId}/files`;
|
||||
|
||||
const listed = await injectWithAuth(server, { method: 'GET', url: `${localPrefix}/list?path=/` });
|
||||
expect(listed.statusCode).toBe(200);
|
||||
expect(listed.json().map((entry: FileEntry) => entry.name)).not.toEqual(expect.arrayContaining(['.env', 'escape']));
|
||||
|
||||
const secretDownload = await injectWithAuth(server, { method: 'GET', url: `${localPrefix}/download?path=/.env` });
|
||||
expect(secretDownload.statusCode).toBe(404);
|
||||
|
||||
const secretUpload = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${localPrefix}/upload`,
|
||||
payload: { path: '/', name: '.env', data: Buffer.from('overwrite').toString('base64') },
|
||||
});
|
||||
expect(secretUpload.statusCode).toBe(400);
|
||||
|
||||
const escapeUpload = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${localPrefix}/upload`,
|
||||
payload: { path: '/escape', name: 'new.txt', data: Buffer.from('outside').toString('base64') },
|
||||
});
|
||||
expect(escapeUpload.statusCode).toBe(400);
|
||||
expect(fs.existsSync(path.join(outside, 'new.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Storage Provider Unit Tests ────────────────────────────────
|
||||
@@ -501,6 +605,294 @@ describe('FsStorageProvider', () => {
|
||||
expect(firstDir).toBeLessThan(firstFile);
|
||||
}
|
||||
});
|
||||
|
||||
it('denies reads and deepest-existing writes through an escaping junction', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-root-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-outside-'));
|
||||
const link = path.join(root, 'escape');
|
||||
fs.writeFileSync(path.join(outside, 'secret.txt'), 'outside');
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outside, link, 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
|
||||
await expect(provider.read('/escape/secret.txt')).rejects.toThrow(/symlink|workspace root/i);
|
||||
await expect(provider.write('/escape/new/deep/file.txt', Buffer.from('outside'))).rejects.toThrow(/symlink|workspace root/i);
|
||||
expect(fs.existsSync(path.join(outside, 'new', 'deep', 'file.txt'))).toBe(false);
|
||||
expect(await provider.exists('/escape/secret.txt')).toBe(false);
|
||||
await expect(provider.list('/escape')).rejects.toThrow(/symlink|workspace root/i);
|
||||
expect((await provider.list('/')).map(entry => entry.name)).not.toContain('escape');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows an in-root junction including a not-yet-created descendant', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-inroot-'));
|
||||
const real = path.join(root, 'real');
|
||||
const link = path.join(root, 'alias');
|
||||
fs.mkdirSync(real);
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(real, link, 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
await provider.write('/alias/new/deep/file.txt', Buffer.from('inside'));
|
||||
|
||||
expect((await provider.read('/alias/new/deep/file.txt')).toString()).toBe('inside');
|
||||
expect(fs.readFileSync(path.join(real, 'new', 'deep', 'file.txt'), 'utf8')).toBe('inside');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows destination creation operations through an in-root junction', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-inroot-dest-'));
|
||||
const real = path.join(root, 'real');
|
||||
const link = path.join(root, 'alias');
|
||||
fs.mkdirSync(real);
|
||||
fs.writeFileSync(path.join(root, 'move-source.txt'), 'move');
|
||||
fs.writeFileSync(path.join(root, 'copy-source.txt'), 'copy');
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(real, link, 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
|
||||
const created = await provider.mkdir('/alias/made/deep');
|
||||
expect(created.path).toBe('/alias/made/deep');
|
||||
expect(fs.statSync(path.join(real, 'made', 'deep')).isDirectory()).toBe(true);
|
||||
|
||||
const moved = await provider.move('/move-source.txt', '/alias/moved/deep/file.txt');
|
||||
expect(moved.path).toBe('/alias/moved/deep/file.txt');
|
||||
expect(fs.readFileSync(path.join(real, 'moved', 'deep', 'file.txt'), 'utf8')).toBe('move');
|
||||
|
||||
const copied = await provider.copy('/copy-source.txt', '/alias/copied/deep/file.txt');
|
||||
expect(copied.path).toBe('/alias/copied/deep/file.txt');
|
||||
expect(fs.readFileSync(path.join(real, 'copied', 'deep', 'file.txt'), 'utf8')).toBe('copy');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('moves and copies onto lexical leaves without overwriting their resolved targets', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-move-link-'));
|
||||
const moveTarget = path.join(root, 'move-target.txt');
|
||||
const moveLink = path.join(root, 'move-alias.txt');
|
||||
const copyTarget = path.join(root, 'copy-target.txt');
|
||||
const copyLink = path.join(root, 'copy-alias.txt');
|
||||
fs.writeFileSync(moveTarget, 'move-target');
|
||||
fs.writeFileSync(moveLink, 'move-placeholder');
|
||||
fs.writeFileSync(path.join(root, 'move-source.txt'), 'move-source');
|
||||
fs.writeFileSync(copyTarget, 'copy-target');
|
||||
fs.writeFileSync(copyLink, 'copy-placeholder');
|
||||
fs.writeFileSync(path.join(root, 'copy-source.txt'), 'copy-source');
|
||||
const resolvedLeaves = new Map([
|
||||
[moveLink, moveTarget],
|
||||
[copyLink, copyTarget],
|
||||
]);
|
||||
const originalRealpath = fs.realpathSync.bind(fs);
|
||||
const realpathSpy = vi.spyOn(fs, 'realpathSync').mockImplementation(((candidate: fs.PathLike) => {
|
||||
const replacement = resolvedLeaves.get(path.resolve(candidate.toString()));
|
||||
if (replacement) return replacement;
|
||||
return originalRealpath(candidate);
|
||||
}) as typeof fs.realpathSync);
|
||||
|
||||
try {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
const moved = await provider.move('/move-source.txt', '/move-alias.txt');
|
||||
const copied = await provider.copy('/copy-source.txt', '/copy-alias.txt');
|
||||
|
||||
expect(moved.path).toBe('/move-alias.txt');
|
||||
expect(copied.path).toBe('/copy-alias.txt');
|
||||
expect(realpathSpy.mock.calls.some(([candidate]) => path.resolve(candidate.toString()) === moveLink)).toBe(true);
|
||||
expect(realpathSpy.mock.calls.some(([candidate]) => path.resolve(candidate.toString()) === copyLink)).toBe(true);
|
||||
expect(fs.readFileSync(moveTarget, 'utf8')).toBe('move-target');
|
||||
expect(fs.readFileSync(moveLink, 'utf8')).toBe('move-source');
|
||||
expect(fs.readFileSync(copyTarget, 'utf8')).toBe('copy-target');
|
||||
expect(fs.readFileSync(copyLink, 'utf8')).toBe('copy-source');
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a dangling junction before the filesystem operation', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-dangling-root-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-dangling-outside-'));
|
||||
const link = path.join(root, 'dangling');
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outside, link, 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
expect(fs.existsSync(link)).toBe(false);
|
||||
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
|
||||
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
await expect(provider.write('/dangling/new.txt', Buffer.from('outside'))).rejects.toThrow(/Invalid path/i);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('denies recursive copy when a nested junction escapes the root', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-copy-root-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-copy-outside-'));
|
||||
const source = path.join(root, 'source');
|
||||
fs.mkdirSync(source);
|
||||
fs.writeFileSync(path.join(outside, 'secret.txt'), 'outside');
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(source, 'escape'), 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
|
||||
await expect(provider.copy('/source', '/copied')).rejects.toThrow(/symlink|workspace root/i);
|
||||
expect(fs.existsSync(path.join(root, 'copied', 'escape', 'secret.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('denies recursive merge-copy through a destination junction', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-dest-root-'));
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-dest-outside-'));
|
||||
const sourceNested = path.join(root, 'source', 'nested');
|
||||
const destination = path.join(root, 'destination');
|
||||
fs.mkdirSync(sourceNested, { recursive: true });
|
||||
fs.mkdirSync(destination);
|
||||
fs.writeFileSync(path.join(sourceNested, 'payload.txt'), 'outside');
|
||||
|
||||
try {
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(destination, 'nested'), 'junction');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
await expect(provider.copy('/source', '/destination')).rejects.toThrow(/symlink|workspace root/i);
|
||||
expect(fs.existsSync(path.join(outside, 'payload.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('denies sensitive operations and hides sensitive metadata for linked roots', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-sensitive-'));
|
||||
fs.writeFileSync(path.join(root, '.env'), 'TOKEN=secret');
|
||||
fs.writeFileSync(path.join(root, 'credentials.json'), '{}');
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'safe');
|
||||
|
||||
try {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
|
||||
await expect(provider.read('/.env')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.write('/.ssh/authorized_keys', Buffer.from('key'))).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.delete('/credentials.json')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.move('/README.md', '/.env')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.copy('/README.md', '/credentials.json')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.mkdir('/.aws')).rejects.toThrow(/sensitive file denied/i);
|
||||
expect(await provider.exists('/.env')).toBe(false);
|
||||
|
||||
const names = (await provider.list('/')).map(entry => entry.name);
|
||||
expect(names).toContain('README.md');
|
||||
expect(names).not.toContain('.env');
|
||||
expect(names).not.toContain('credentials.json');
|
||||
expect(fs.readFileSync(path.join(root, 'credentials.json'), 'utf8')).toBe('{}');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('denies recursive operations on directories containing sensitive files', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-sensitive-tree-'));
|
||||
for (const name of ['copy-source', 'move-source', 'delete-source']) {
|
||||
fs.mkdirSync(path.join(root, name));
|
||||
fs.writeFileSync(path.join(root, name, '.env'), 'TOKEN=secret');
|
||||
}
|
||||
|
||||
try {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root, { denySensitive: true });
|
||||
|
||||
await expect(provider.copy('/copy-source', '/copy-target')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.move('/move-source', '/move-target')).rejects.toThrow(/sensitive file denied/i);
|
||||
await expect(provider.delete('/delete-source')).rejects.toThrow(/sensitive file denied/i);
|
||||
expect(fs.existsSync(path.join(root, 'copy-target'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(root, 'move-source', '.env'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(root, 'delete-source', '.env'))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves sensitive-looking files in app-managed roots', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-managed-'));
|
||||
|
||||
try {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(root);
|
||||
await provider.write('/.env', Buffer.from('documented workspace content'));
|
||||
|
||||
expect((await provider.read('/.env')).toString()).toBe('documented workspace content');
|
||||
expect((await provider.list('/')).map(entry => entry.name)).toContain('.env');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('enables the sensitive deny only for local workspace storage', async () => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-storage-policy-'));
|
||||
const linkedRoot = path.join(dataDir, 'linked');
|
||||
fs.mkdirSync(linkedRoot);
|
||||
fs.writeFileSync(path.join(linkedRoot, '.env'), 'TOKEN=secret');
|
||||
|
||||
try {
|
||||
const { getStorageProvider } = await import('../../src/local/storage/index.js');
|
||||
const linked = getStorageProvider({ id: 'linked', storageType: 'local', storagePath: linkedRoot }, dataDir);
|
||||
const managed = getStorageProvider({ id: 'managed', storageType: 'virtual' }, dataDir);
|
||||
|
||||
await expect(linked.read('/.env')).rejects.toThrow(/sensitive file denied/i);
|
||||
await managed.write('/.env', Buffer.from('managed workspace content'));
|
||||
expect((await managed.read('/.env')).toString()).toBe('managed workspace content');
|
||||
} finally {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Security', () => {
|
||||
|
||||
Reference in New Issue
Block a user