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:
Kris
2026-06-29 11:38:51 +05:30
co-authored by Claude Opus 4.8
parent 9feaa0d6e5
commit 40e26c5422
2 changed files with 89 additions and 2 deletions
+33 -2
View File
@@ -9,12 +9,14 @@
import { v4 as uuidv4 } from 'uuid';
import { readFile } from 'node:fs/promises';
import { statSync } from 'node:fs';
import { statSync, realpathSync } from 'node:fs';
import { Session } from '../session.js';
import { SseEvent } from '../web/sse-events.js';
import { getErrorMessage } from '../types/api.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 { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../config/attachment-guard.js';
import { validateSessionFilePath } from '../web/route-helpers.js';
import { computeNextRunAt, dueKeyFor } from './cron-time.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../web/ports/index.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> {
if (job.promptMode === 'prompt_file_path') {
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 ?? '';
}
/**
* 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 {
setImmediate(() => {
const poll = async (): Promise<void> => {