mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix: enforce 600 perms for cron store and run logs (#36078)
* fix: enforce secure permissions for cron store and run logs * fix(cron): enforce dir perms and gate posix tests on windows * Cron store tests: cover existing directory permission hardening * Cron run-log tests: cover existing directory permission hardening * Changelog: note cron file permission hardening --------- Co-authored-by: linhey <linhey@mini.local> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -75,6 +75,10 @@ export function resolveCronRunLogPath(params: { storePath: string; jobId: string
|
||||
|
||||
const writesByPath = new Map<string, Promise<void>>();
|
||||
|
||||
async function setSecureFileMode(filePath: string): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -56,12 +56,18 @@ type SaveCronStoreOptions = {
|
||||
skipBackup?: boolean;
|
||||
};
|
||||
|
||||
async function setSecureFileMode(filePath: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user