diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6e97310ba..fac5111ac0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream `terminated` failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard. - Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for `rate_limit` (instead of failing pre-run as `No available auth profile`), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura. - Cron/OpenAI Codex OAuth refresh hardening: when `openai-codex` token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal. +- Cron/file permission hardening: enforce owner-only (`0600`) cron store/backup/run-log files and harden cron store + run-log directories to `0700`, including pre-existing directories from older installs. (#36078) Thanks @aerelune. - Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn. - Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. diff --git a/src/cron/run-log.test.ts b/src/cron/run-log.test.ts index 3cf1ee1cad2..728cb039aab 100644 --- a/src/cron/run-log.test.ts +++ b/src/cron/run-log.test.ts @@ -95,6 +95,47 @@ describe("cron run log", () => { }); }); + it.skipIf(process.platform === "win32")( + "writes run log files with secure permissions", + async () => { + await withRunLogDir("openclaw-cron-log-perms-", async (dir) => { + const logPath = path.join(dir, "runs", "job-1.jsonl"); + + await appendCronRunLog(logPath, { + ts: 1, + jobId: "job-1", + action: "finished", + status: "ok", + }); + + const mode = (await fs.stat(logPath)).mode & 0o777; + expect(mode).toBe(0o600); + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "hardens an existing run-log directory to owner-only permissions", + async () => { + await withRunLogDir("openclaw-cron-log-dir-perms-", async (dir) => { + const runDir = path.join(dir, "runs"); + const logPath = path.join(runDir, "job-1.jsonl"); + await fs.mkdir(runDir, { recursive: true, mode: 0o755 }); + await fs.chmod(runDir, 0o755); + + await appendCronRunLog(logPath, { + ts: 1, + jobId: "job-1", + action: "finished", + status: "ok", + }); + + const runDirMode = (await fs.stat(runDir)).mode & 0o777; + expect(runDirMode).toBe(0o700); + }); + }, + ); + it("reads newest entries and filters by jobId", async () => { await withRunLogDir("openclaw-cron-log-read-", async (dir) => { const logPathA = path.join(dir, "runs", "a.jsonl"); diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index ce82c693c25..e2a7f2b8121 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -75,6 +75,10 @@ export function resolveCronRunLogPath(params: { storePath: string; jobId: string const writesByPath = new Map>(); +async function setSecureFileMode(filePath: string): Promise { + await fs.chmod(filePath, 0o600).catch(() => undefined); +} + export const DEFAULT_CRON_RUN_LOG_MAX_BYTES = 2_000_000; export const DEFAULT_CRON_RUN_LOG_KEEP_LINES = 2_000; @@ -125,8 +129,10 @@ async function pruneIfNeeded(filePath: string, opts: { maxBytes: number; keepLin const kept = lines.slice(Math.max(0, lines.length - opts.keepLines)); const { randomBytes } = await import("node:crypto"); const tmp = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; - await fs.writeFile(tmp, `${kept.join("\n")}\n`, "utf-8"); + await fs.writeFile(tmp, `${kept.join("\n")}\n`, { encoding: "utf-8", mode: 0o600 }); + await setSecureFileMode(tmp); await fs.rename(tmp, filePath); + await setSecureFileMode(filePath); } export async function appendCronRunLog( @@ -139,8 +145,14 @@ export async function appendCronRunLog( const next = prev .catch(() => undefined) .then(async () => { - await fs.mkdir(path.dirname(resolved), { recursive: true }); - await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, "utf-8"); + const runDir = path.dirname(resolved); + await fs.mkdir(runDir, { recursive: true, mode: 0o700 }); + await fs.chmod(runDir, 0o700).catch(() => undefined); + await fs.appendFile(resolved, `${JSON.stringify(entry)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + await setSecureFileMode(resolved); await pruneIfNeeded(resolved, { maxBytes: opts?.maxBytes ?? DEFAULT_CRON_RUN_LOG_MAX_BYTES, keepLines: opts?.keepLines ?? DEFAULT_CRON_RUN_LOG_KEEP_LINES, diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 1d318671437..f511636fb85 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -79,6 +79,39 @@ describe("cron store", () => { expect(JSON.parse(currentRaw)).toEqual(second); expect(JSON.parse(backupRaw)).toEqual(first); }); + + it.skipIf(process.platform === "win32")( + "writes store and backup files with secure permissions", + async () => { + const store = await makeStorePath(); + const first = makeStore("job-1", true); + const second = makeStore("job-2", false); + + await saveCronStore(store.storePath, first); + await saveCronStore(store.storePath, second); + + const storeMode = (await fs.stat(store.storePath)).mode & 0o777; + const backupMode = (await fs.stat(`${store.storePath}.bak`)).mode & 0o777; + + expect(storeMode).toBe(0o600); + expect(backupMode).toBe(0o600); + }, + ); + + it.skipIf(process.platform === "win32")( + "hardens an existing cron store directory to owner-only permissions", + async () => { + const store = await makeStorePath(); + const storeDir = path.dirname(store.storePath); + await fs.mkdir(storeDir, { recursive: true, mode: 0o755 }); + await fs.chmod(storeDir, 0o755); + + await saveCronStore(store.storePath, makeStore("job-1", true)); + + const storeDirMode = (await fs.stat(storeDir)).mode & 0o777; + expect(storeDirMode).toBe(0o700); + }, + ); }); describe("saveCronStore", () => { diff --git a/src/cron/store.ts b/src/cron/store.ts index 70fd978aab6..8e8f0440f35 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -56,12 +56,18 @@ type SaveCronStoreOptions = { skipBackup?: boolean; }; +async function setSecureFileMode(filePath: string): Promise { + await fs.promises.chmod(filePath, 0o600).catch(() => undefined); +} + export async function saveCronStore( storePath: string, store: CronStoreFile, opts?: SaveCronStoreOptions, ) { - await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); + const storeDir = path.dirname(storePath); + await fs.promises.mkdir(storeDir, { recursive: true, mode: 0o700 }); + await fs.promises.chmod(storeDir, 0o700).catch(() => undefined); const json = JSON.stringify(store, null, 2); const cached = serializedStoreCache.get(storePath); if (cached === json) { @@ -83,15 +89,19 @@ export async function saveCronStore( return; } const tmp = `${storePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; - await fs.promises.writeFile(tmp, json, "utf-8"); + await fs.promises.writeFile(tmp, json, { encoding: "utf-8", mode: 0o600 }); + await setSecureFileMode(tmp); if (previous !== null && !opts?.skipBackup) { try { - await fs.promises.copyFile(storePath, `${storePath}.bak`); + const backupPath = `${storePath}.bak`; + await fs.promises.copyFile(storePath, backupPath); + await setSecureFileMode(backupPath); } catch { // best-effort } } await renameWithRetry(tmp, storePath); + await setSecureFileMode(storePath); serializedStoreCache.set(storePath, json); }