mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix(cron): confine cron prompt-file reads to block sensitive paths
A cron job's promptFilePath is user-supplied via the API and was read with an unconfined readFile of any absolute path, so a hostile job config could exfil arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session. Guard the read in resolvePrompt by mirroring the attachment-serving guard (resolveServableAttachmentPath in file-routes): realpath-resolve the path, then reject via the shared blocklist (/etc, /root, secret locations) plus the optional workspace-confinement toggle before reading. Regression tests in cron-service.test.ts: blocks /etc/passwd (the live repro) and /root/*, fails cleanly on a missing file, and still allows an ordinary prompt file outside the blocklist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
This commit is contained in:
@@ -9,12 +9,14 @@
|
|||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { statSync } from 'node:fs';
|
import { statSync, realpathSync } from 'node:fs';
|
||||||
import { Session } from '../session.js';
|
import { Session } from '../session.js';
|
||||||
import { SseEvent } from '../web/sse-events.js';
|
import { SseEvent } from '../web/sse-events.js';
|
||||||
import { getErrorMessage } from '../types/api.js';
|
import { getErrorMessage } from '../types/api.js';
|
||||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||||
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js';
|
||||||
|
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../config/attachment-guard.js';
|
||||||
|
import { validateSessionFilePath } from '../web/route-helpers.js';
|
||||||
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
|
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
|
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.js';
|
||||||
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
import type { CronJob, CronJobRun, CronJobRunStatus, TriggerType } from '../types/cron.js';
|
||||||
@@ -288,11 +290,40 @@ export class CronService {
|
|||||||
private async resolvePrompt(job: CronJob): Promise<string> {
|
private async resolvePrompt(job: CronJob): Promise<string> {
|
||||||
if (job.promptMode === 'prompt_file_path') {
|
if (job.promptMode === 'prompt_file_path') {
|
||||||
if (!job.promptFilePath) throw new Error('prompt file path is empty');
|
if (!job.promptFilePath) throw new Error('prompt file path is empty');
|
||||||
return readFile(job.promptFilePath, 'utf-8');
|
const safePath = await this.resolveSafePromptPath(job.promptFilePath, job.workingDir);
|
||||||
|
return readFile(safePath, 'utf-8');
|
||||||
}
|
}
|
||||||
return job.promptText ?? '';
|
return job.promptText ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards a prompt-file path before it is read. The path is user-supplied via
|
||||||
|
* the API, so without this an attacker (or a hostile job config) could read
|
||||||
|
* arbitrary host files (e.g. /etc/passwd, SSH keys) into a Claude session.
|
||||||
|
*
|
||||||
|
* Mirrors the attachment-serving guard (`resolveServableAttachmentPath` in
|
||||||
|
* file-routes): realpath-resolve, then reject via the shared blocklist
|
||||||
|
* (`/etc`, `/root`, secret locations) plus optional workspace confinement.
|
||||||
|
* Returns the symlink-resolved path to read.
|
||||||
|
*/
|
||||||
|
private async resolveSafePromptPath(rawPath: string, workingDir: string): Promise<string> {
|
||||||
|
let resolved: string;
|
||||||
|
try {
|
||||||
|
resolved = realpathSync(rawPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error('prompt file path could not be resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
const blocked =
|
||||||
|
isBlockedAttachmentPath(resolved, guard.blockedTrees) ||
|
||||||
|
isBlockedAttachmentPath(rawPath, guard.blockedTrees) ||
|
||||||
|
(guard.confineToWorkspace && !validateSessionFilePath(workingDir, resolved));
|
||||||
|
|
||||||
|
if (blocked) throw new Error('prompt file path is blocked');
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void {
|
private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
const poll = async (): Promise<void> => {
|
const poll = async (): Promise<void> => {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
|
import { CronService, type CronDeps } from '../src/cron/cron-service.js';
|
||||||
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
||||||
import type { CronJobInput } from '../src/cron/cron-input.js';
|
import type { CronJobInput } from '../src/cron/cron-input.js';
|
||||||
@@ -245,6 +248,59 @@ describe('CronService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolvePrompt path guard', () => {
|
||||||
|
it('blocks a prompt_file_path pointing at a sensitive system file', async () => {
|
||||||
|
const job = svc.service.createJob(
|
||||||
|
mkInput({ promptMode: 'prompt_file_path', promptFilePath: '/etc/passwd', promptText: undefined })
|
||||||
|
);
|
||||||
|
const run = await svc.service.runNow(job.id);
|
||||||
|
expect(run).not.toBeNull();
|
||||||
|
expect(run!.status).toBe('failed');
|
||||||
|
// Must fail at prompt resolution (blocked), NOT later at the missing workingDir —
|
||||||
|
// i.e. the file content must never be read.
|
||||||
|
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||||
|
expect(run!.errorMessage).toMatch(/block/i);
|
||||||
|
// No session was created for a blocked job.
|
||||||
|
expect(svc.sessions.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a prompt_file_path under a default-blocked tree (/root)', async () => {
|
||||||
|
const job = svc.service.createJob(
|
||||||
|
mkInput({ promptMode: 'prompt_file_path', promptFilePath: '/root/.bashrc', promptText: undefined })
|
||||||
|
);
|
||||||
|
const run = await svc.service.runNow(job.id);
|
||||||
|
expect(run!.status).toBe('failed');
|
||||||
|
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails cleanly (no throw) when the prompt file does not exist', async () => {
|
||||||
|
const job = svc.service.createJob(
|
||||||
|
mkInput({
|
||||||
|
promptMode: 'prompt_file_path',
|
||||||
|
promptFilePath: '/tmp/codeman-cron-no-such-prompt-file.md',
|
||||||
|
promptText: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const run = await svc.service.runNow(job.id);
|
||||||
|
expect(run!.status).toBe('failed');
|
||||||
|
expect(run!.errorMessage).toMatch(/Prompt error/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an ordinary prompt file outside the blocklist (passes resolution)', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'codeman-cron-prompt-'));
|
||||||
|
const file = join(dir, 'prompt.md');
|
||||||
|
writeFileSync(file, 'do the thing');
|
||||||
|
const job = svc.service.createJob(
|
||||||
|
mkInput({ promptMode: 'prompt_file_path', promptFilePath: file, promptText: undefined })
|
||||||
|
);
|
||||||
|
const run = await svc.service.runNow(job.id);
|
||||||
|
expect(run!.status).toBe('failed'); // still fails — workingDir (MISSING_DIR) does not exist
|
||||||
|
// ...but it got PAST prompt resolution: the failure is the workingDir, not a Prompt error.
|
||||||
|
expect(run!.errorMessage).not.toMatch(/Prompt error/i);
|
||||||
|
expect(run!.errorMessage).toMatch(/workingDir/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('runNow', () => {
|
describe('runNow', () => {
|
||||||
it('launches regardless of enabled/schedule state', async () => {
|
it('launches regardless of enabled/schedule state', async () => {
|
||||||
const job = svc.service.createJob(mkInput({ enabled: false }));
|
const job = svc.service.createJob(mkInput({ enabled: false }));
|
||||||
|
|||||||
Reference in New Issue
Block a user