Configure attachment allowlist dirs

Add EJCLAW_ATTACHMENT_ALLOWED_DIRS for explicit local image folders while keeping outbound attachment validation strict.
This commit is contained in:
Eyejoker
2026-05-02 22:40:41 +09:00
committed by GitHub
parent 4859c58f48
commit a4b7aef3bd
4 changed files with 81 additions and 3 deletions

View File

@@ -2,7 +2,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { validateOutboundAttachments } from './outbound-attachments.js';
@@ -31,6 +31,7 @@ function writeFile(
}
afterEach(() => {
vi.unstubAllEnvs();
for (const dir of cleanupDirs.splice(0)) {
fs.rmSync(dir, { force: true, recursive: true });
}
@@ -154,6 +155,30 @@ describe('validateOutboundAttachments', () => {
]);
});
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', () => {
const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-');
const targetPath = writeFile(
@@ -173,6 +198,22 @@ describe('validateOutboundAttachments', () => {
]);
});
it('rejects symlink attempts that escape a user-configured directory', () => {
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>');

View File

@@ -3,6 +3,7 @@ import os from 'os';
import path from 'path';
import { DATA_DIR } from './config.js';
import { getEnv } from './env.js';
import type { OutboundAttachment } from './types.js';
export interface ValidatedOutboundAttachment {
@@ -32,10 +33,29 @@ function resolveExistingDir(dir: string): string | null {
}
}
function expandHomeDir(dir: string): string {
const home = getEnv('HOME') || os.homedir();
if (dir === '~') return home;
if (dir.startsWith(`~${path.sep}`)) return path.join(home, dir.slice(2));
return dir;
}
export function getConfiguredAttachmentBaseDirs(): string[] {
const raw = getEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS') ?? '';
return unique(
raw
.split(/[,\n]/)
.flatMap((part) => part.split(path.delimiter))
.map((value) => value.trim())
.filter(Boolean)
.map(expandHomeDir),
);
}
export function getDefaultAttachmentBaseDirs(): string[] {
const home = process.env.HOME;
const home = getEnv('HOME');
const codexHome =
process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null);
getEnv('CODEX_HOME') || (home ? path.join(home, '.codex') : null);
// Keep defaults narrow. Runtime-specific workspaces must be passed via
// attachmentBaseDirs so one room cannot attach another room's files by path.
// Ad-hoc generated files under os.tmpdir() are handled separately after
@@ -43,6 +63,7 @@ export function getDefaultAttachmentBaseDirs(): string[] {
return unique([
path.join(DATA_DIR, 'attachments'),
codexHome ? path.join(codexHome, 'generated_images') : null,
...getConfiguredAttachmentBaseDirs(),
])
.map(resolveExistingDir)
.filter((dir): dir is string => Boolean(dir));