* security: path traversal, query bounds, marker injection fixes LocalStorage: contained() method validates all paths stay within storage root. file-resolver: resolveFile validates filePath within brainRoot, marker prefix rejects ../, absolute paths, bare '..'. file_list: LIMIT 100 on slug-filtered branch + FILE_LIST_LIMIT constant for both branches. Co-Authored-By: Gus <garagon@users.noreply.github.com> * security: symlink hardening in all file walkers All 4 walkers in files.ts (collectFiles, findRedirects, findAndClean, scan) plus init.ts counter now use lstatSync + isSymbolicLink skip. Tests import production collectFiles instead of reimplementing it. node_modules skipped. CLI file list and verify queries bounded with LIMIT. Co-Authored-By: Gus <garagon@users.noreply.github.com> * feat: typed health check DSL + recipe migration 4 DSL types: http, env_exists, command, any_of. Replaces raw execSync on recipe YAML. All 7 first-party recipes migrated from shell strings to typed objects. String health_checks still accepted with deprecation warning + metachar validation for non-embedded recipes. isUnsafeHealthCheck blocks shell injection for user-created recipes. Co-Authored-By: Gus <garagon@users.noreply.github.com> * chore: bump version and changelog (v0.9.3) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: E2E test for file_list LIMIT enforcement against real Postgres Inserts 150 file rows for one slug, verifies file_list returns at most 100 (both slug-filtered and unfiltered branches). Proves the LIMIT works at the database level, not just in unit tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Gus <garagon@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { LocalStorage } from '../src/core/storage/local.ts';
|
|
import { createStorage } from '../src/core/storage.ts';
|
|
|
|
describe('LocalStorage', () => {
|
|
let storage: LocalStorage;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-storage-test-'));
|
|
storage = new LocalStorage(tmpDir);
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(tmpDir, { recursive: true });
|
|
});
|
|
|
|
test('upload creates file', async () => {
|
|
await storage.upload('test/file.txt', Buffer.from('hello'));
|
|
expect(existsSync(join(tmpDir, 'test/file.txt'))).toBe(true);
|
|
});
|
|
|
|
test('download returns uploaded data', async () => {
|
|
await storage.upload('test/roundtrip.bin', Buffer.from('binary data'));
|
|
const data = await storage.download('test/roundtrip.bin');
|
|
expect(data.toString()).toBe('binary data');
|
|
});
|
|
|
|
test('download throws for missing file', async () => {
|
|
expect(storage.download('nonexistent.txt')).rejects.toThrow('not found');
|
|
});
|
|
|
|
test('exists returns true for uploaded file', async () => {
|
|
await storage.upload('test/exists.txt', Buffer.from('x'));
|
|
expect(await storage.exists('test/exists.txt')).toBe(true);
|
|
});
|
|
|
|
test('exists returns false for missing file', async () => {
|
|
expect(await storage.exists('nope.txt')).toBe(false);
|
|
});
|
|
|
|
test('delete removes file', async () => {
|
|
await storage.upload('test/deleteme.txt', Buffer.from('x'));
|
|
await storage.delete('test/deleteme.txt');
|
|
expect(await storage.exists('test/deleteme.txt')).toBe(false);
|
|
});
|
|
|
|
test('delete is idempotent (missing file is ok)', async () => {
|
|
await storage.delete('already-gone.txt');
|
|
// No throw
|
|
});
|
|
|
|
test('list returns uploaded files', async () => {
|
|
await storage.upload('listdir/a.txt', Buffer.from('a'));
|
|
await storage.upload('listdir/b.txt', Buffer.from('b'));
|
|
await storage.upload('listdir/sub/c.txt', Buffer.from('c'));
|
|
const files = await storage.list('listdir');
|
|
expect(files.length).toBe(3);
|
|
expect(files).toContain('listdir/a.txt');
|
|
expect(files).toContain('listdir/b.txt');
|
|
expect(files).toContain('listdir/sub/c.txt');
|
|
});
|
|
|
|
test('list returns empty for missing prefix', async () => {
|
|
const files = await storage.list('nonexistent-prefix');
|
|
expect(files.length).toBe(0);
|
|
});
|
|
|
|
test('getUrl returns file:// URL', async () => {
|
|
const url = await storage.getUrl('test/file.txt');
|
|
expect(url.startsWith('file://')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// --- Path traversal containment ---
|
|
|
|
describe('LocalStorage path traversal', () => {
|
|
test('blocks upload path traversal via ../', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
|
|
try {
|
|
const storage = new LocalStorage(tmpDir);
|
|
await expect(storage.upload('../../etc/evil', Buffer.from('pwned'))).rejects.toThrow('Path traversal blocked');
|
|
await expect(storage.upload('../sibling/file', Buffer.from('x'))).rejects.toThrow('Path traversal blocked');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test('blocks download path traversal via ../', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
|
|
try {
|
|
const storage = new LocalStorage(tmpDir);
|
|
await expect(storage.download('../../etc/passwd')).rejects.toThrow('Path traversal blocked');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test('blocks delete path traversal via ../', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
|
|
try {
|
|
const storage = new LocalStorage(tmpDir);
|
|
await expect(storage.delete('../../../tmp/important')).rejects.toThrow('Path traversal blocked');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test('blocks list path traversal via ../', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
|
|
try {
|
|
const storage = new LocalStorage(tmpDir);
|
|
await expect(storage.list('../../etc')).rejects.toThrow('Path traversal blocked');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test('blocks getUrl path traversal via ../', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
|
|
try {
|
|
const storage = new LocalStorage(tmpDir);
|
|
await expect(storage.getUrl('../../etc/passwd')).rejects.toThrow('Path traversal blocked');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test('allows legitimate nested paths', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
|
|
try {
|
|
const storage = new LocalStorage(tmpDir);
|
|
await storage.upload('pages/people/elon/avatar.png', Buffer.from('img'));
|
|
const data = await storage.download('pages/people/elon/avatar.png');
|
|
expect(data.toString()).toBe('img');
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('createStorage', () => {
|
|
test('creates LocalStorage for backend: local', async () => {
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-factory-test-'));
|
|
try {
|
|
const storage = await createStorage({ backend: 'local', bucket: 'test', localPath: tmpDir });
|
|
await storage.upload('test.txt', Buffer.from('hello'));
|
|
expect(await storage.exists('test.txt')).toBe(true);
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test('throws for unknown backend', async () => {
|
|
expect(createStorage({ backend: 'unknown' as any, bucket: 'test' })).rejects.toThrow('Unknown storage backend');
|
|
});
|
|
|
|
test('S3Storage requires credentials', async () => {
|
|
expect(createStorage({ backend: 's3', bucket: 'test' })).rejects.toThrow('accessKeyId');
|
|
});
|
|
|
|
test('SupabaseStorage requires projectUrl', async () => {
|
|
expect(createStorage({ backend: 'supabase', bucket: 'test' })).rejects.toThrow('projectUrl');
|
|
});
|
|
});
|