328 lines
9.9 KiB
TypeScript
328 lines
9.9 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { validateOutboundAttachments } from './outbound-attachments.js';
|
|
|
|
const ONE_PIXEL_PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
const MINIMAL_MP4 = Buffer.from([
|
|
0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00,
|
|
0x00, 0x02, 0x00, 0x69, 0x73, 0x6f, 0x6d, 0x69, 0x73, 0x6f, 0x32,
|
|
]);
|
|
const MINIMAL_PDF = Buffer.from('%PDF-1.4\n', 'ascii');
|
|
const MINIMAL_ZIP = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00]);
|
|
|
|
const cleanupDirs: string[] = [];
|
|
const cleanupFiles: string[] = [];
|
|
|
|
function makeTempDir(baseDir: string, prefix: string): string {
|
|
const dir = fs.mkdtempSync(path.join(baseDir, prefix));
|
|
cleanupDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function writeFile(
|
|
dir: string,
|
|
name: string,
|
|
content: Buffer | string,
|
|
): string {
|
|
const filePath = path.join(dir, name);
|
|
fs.writeFileSync(filePath, content);
|
|
return filePath;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
vi.restoreAllMocks();
|
|
for (const dir of cleanupDirs.splice(0)) {
|
|
fs.rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
for (const file of cleanupFiles.splice(0)) {
|
|
fs.rmSync(file, { force: true });
|
|
}
|
|
});
|
|
|
|
describe('validateOutboundAttachments', () => {
|
|
it('accepts real image files under default attachment directories', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
|
const imagePath = writeFile(dir, 'screenshot.png', ONE_PIXEL_PNG);
|
|
|
|
const result = validateOutboundAttachments([
|
|
{
|
|
path: imagePath,
|
|
name: '../unsafe-name.png',
|
|
mime: 'image/png',
|
|
},
|
|
]);
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(imagePath),
|
|
name: 'unsafe-name.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('accepts direct EJClaw screenshot files under the temp directory', () => {
|
|
const imagePath = path.join(
|
|
os.tmpdir(),
|
|
`ejclaw-room-mobile-list-${Date.now()}.png`,
|
|
);
|
|
fs.writeFileSync(imagePath, ONE_PIXEL_PNG);
|
|
cleanupFiles.push(imagePath);
|
|
|
|
const result = validateOutboundAttachments([
|
|
{
|
|
path: imagePath,
|
|
name: 'room-list.png',
|
|
mime: 'image/png',
|
|
},
|
|
]);
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(imagePath),
|
|
name: 'room-list.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('accepts direct generated screenshot files under the temp directory', () => {
|
|
const imagePath = path.join(
|
|
os.tmpdir(),
|
|
`bar-chart-label-fit-playwright-${Date.now()}.png`,
|
|
);
|
|
fs.writeFileSync(imagePath, ONE_PIXEL_PNG);
|
|
cleanupFiles.push(imagePath);
|
|
|
|
const result = validateOutboundAttachments([
|
|
{
|
|
path: imagePath,
|
|
name: 'bar-chart-label-fit-playwright.png',
|
|
mime: 'image/png',
|
|
},
|
|
]);
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(imagePath),
|
|
name: 'bar-chart-label-fit-playwright.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('accepts generated image files in nested temp directories', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'paladin-character-');
|
|
const imagePath = writeFile(dir, 'sheet_12x.png', ONE_PIXEL_PNG);
|
|
|
|
const result = validateOutboundAttachments([
|
|
{
|
|
path: imagePath,
|
|
name: 'sheet_12x.png',
|
|
mime: 'image/png',
|
|
},
|
|
]);
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(imagePath),
|
|
name: 'sheet_12x.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('accepts supported non-image media and document files', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-media-');
|
|
const mp4Path = writeFile(dir, 'preview.mp4', MINIMAL_MP4);
|
|
const pdfPath = writeFile(dir, 'report.pdf', MINIMAL_PDF);
|
|
const zipPath = writeFile(dir, 'archive.zip', MINIMAL_ZIP);
|
|
const textPath = writeFile(dir, 'notes.txt', 'hello\n');
|
|
|
|
const result = validateOutboundAttachments([
|
|
{ path: mp4Path, mime: 'video/mp4' },
|
|
{ path: pdfPath, mime: 'application/pdf' },
|
|
{ path: zipPath, mime: 'application/zip' },
|
|
{ path: textPath, mime: 'text/plain' },
|
|
]);
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(mp4Path),
|
|
name: 'preview.mp4',
|
|
},
|
|
{
|
|
attachment: fs.realpathSync(pdfPath),
|
|
name: 'report.pdf',
|
|
},
|
|
{
|
|
attachment: fs.realpathSync(zipPath),
|
|
name: 'archive.zip',
|
|
},
|
|
{
|
|
attachment: fs.realpathSync(textPath),
|
|
name: 'notes.txt',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('validateOutboundAttachments policy checks', () => {
|
|
function useIsolatedDefaultTempDir(): void {
|
|
const tempDir = makeTempDir(os.tmpdir(), 'ejclaw-policy-temp-');
|
|
vi.spyOn(os, 'tmpdir').mockReturnValue(tempDir);
|
|
}
|
|
|
|
it('requires workspace paths to be explicitly allowlisted', () => {
|
|
useIsolatedDefaultTempDir();
|
|
const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
|
const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG);
|
|
|
|
expect(validateOutboundAttachments([{ path: imagePath }])).toEqual({
|
|
files: [],
|
|
rejected: [{ path: imagePath, reason: 'outside-allowed-dirs' }],
|
|
});
|
|
|
|
const allowed = validateOutboundAttachments([{ path: imagePath }], {
|
|
baseDirs: [dir],
|
|
});
|
|
|
|
expect(allowed.rejected).toEqual([]);
|
|
expect(allowed.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(imagePath),
|
|
name: 'workspace-shot.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('accepts user-configured attachment directories from env', () => {
|
|
const unusedCommaDir = makeTempDir(process.cwd(), '.ejclaw-unused-images-');
|
|
const unusedDelimiterDir = makeTempDir(
|
|
process.cwd(),
|
|
'.ejclaw-more-unused-images-',
|
|
);
|
|
const dir = makeTempDir(process.cwd(), '.ejclaw-user-images-');
|
|
const imagePath = writeFile(dir, 'manual-shot.png', ONE_PIXEL_PNG);
|
|
vi.stubEnv(
|
|
'EJCLAW_ATTACHMENT_ALLOWED_DIRS',
|
|
`${unusedCommaDir},${unusedDelimiterDir}${path.delimiter}${dir}`,
|
|
);
|
|
|
|
const result = validateOutboundAttachments([{ path: imagePath }]);
|
|
|
|
expect(result.rejected).toEqual([]);
|
|
expect(result.files).toEqual([
|
|
{
|
|
attachment: fs.realpathSync(imagePath),
|
|
name: 'manual-shot.png',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('rejects symlink attempts that escape the allowed directory', () => {
|
|
useIsolatedDefaultTempDir();
|
|
const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
|
|
const targetPath = writeFile(
|
|
workspaceDir,
|
|
'secret-shot.png',
|
|
ONE_PIXEL_PNG,
|
|
);
|
|
const tmpDir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
|
const linkPath = path.join(tmpDir, 'linked-shot.png');
|
|
fs.symlinkSync(targetPath, linkPath);
|
|
|
|
const result = validateOutboundAttachments([{ path: linkPath }]);
|
|
|
|
expect(result.files).toEqual([]);
|
|
expect(result.rejected).toEqual([
|
|
{ path: linkPath, reason: 'outside-allowed-dirs' },
|
|
]);
|
|
});
|
|
|
|
it('rejects symlink attempts that escape a user-configured directory', () => {
|
|
useIsolatedDefaultTempDir();
|
|
const allowedDir = makeTempDir(process.cwd(), '.ejclaw-user-images-');
|
|
const secretDir = makeTempDir(process.cwd(), '.ejclaw-secret-images-');
|
|
const targetPath = writeFile(secretDir, 'secret-shot.png', ONE_PIXEL_PNG);
|
|
const linkPath = path.join(allowedDir, 'linked-shot.png');
|
|
fs.symlinkSync(targetPath, linkPath);
|
|
vi.stubEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS', allowedDir);
|
|
|
|
const result = validateOutboundAttachments([{ path: linkPath }]);
|
|
|
|
expect(result.files).toEqual([]);
|
|
expect(result.rejected).toEqual([
|
|
{ path: linkPath, reason: 'outside-allowed-dirs' },
|
|
]);
|
|
});
|
|
|
|
it('rejects SVG attachments before inspecting file content', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
|
const svgPath = writeFile(dir, 'vector.svg', '<svg></svg>');
|
|
|
|
const result = validateOutboundAttachments([{ path: svgPath }]);
|
|
|
|
expect(result.files).toEqual([]);
|
|
expect(result.rejected).toEqual([
|
|
{ path: svgPath, reason: 'unsupported-extension' },
|
|
]);
|
|
});
|
|
|
|
it('rejects files whose extension and image signature do not match policy', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
|
const fakePng = writeFile(dir, 'fake.png', 'not an image');
|
|
const realPng = writeFile(dir, 'real.png', ONE_PIXEL_PNG);
|
|
|
|
expect(validateOutboundAttachments([{ path: fakePng }])).toEqual({
|
|
files: [],
|
|
rejected: [{ path: fakePng, reason: 'invalid-image-signature' }],
|
|
});
|
|
expect(
|
|
validateOutboundAttachments([{ path: realPng, mime: 'image/jpeg' }]),
|
|
).toEqual({
|
|
files: [],
|
|
rejected: [{ path: realPng, reason: 'mime-mismatch' }],
|
|
});
|
|
});
|
|
|
|
it('rejects supported non-image files with invalid signatures', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-media-');
|
|
const fakeMp4 = writeFile(dir, 'fake.mp4', 'not a movie');
|
|
|
|
expect(validateOutboundAttachments([{ path: fakeMp4 }])).toEqual({
|
|
files: [],
|
|
rejected: [{ path: fakeMp4, reason: 'invalid-file-signature' }],
|
|
});
|
|
});
|
|
|
|
it('rejects non-files and files over the size cap', () => {
|
|
const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-');
|
|
const nestedDir = path.join(dir, 'nested.png');
|
|
fs.mkdirSync(nestedDir);
|
|
const largePng = writeFile(
|
|
dir,
|
|
'large.png',
|
|
Buffer.concat([ONE_PIXEL_PNG, Buffer.alloc(8 * 1024 * 1024)]),
|
|
);
|
|
|
|
expect(validateOutboundAttachments([{ path: nestedDir }])).toEqual({
|
|
files: [],
|
|
rejected: [{ path: nestedDir, reason: 'not-file' }],
|
|
});
|
|
expect(validateOutboundAttachments([{ path: largePng }])).toEqual({
|
|
files: [],
|
|
rejected: [{ path: largePng, reason: 'too-large' }],
|
|
});
|
|
});
|
|
});
|