From 194f0786d4e9da512d2e0e43587a92a3aecc024c Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sun, 17 May 2026 02:05:18 -0300 Subject: [PATCH] fix: reject symlinked whatsapp creds --- CHANGELOG.md | 1 + extensions/whatsapp/src/accounts.ts | 8 +- .../src/accounts.whatsapp-auth.test.ts | 36 ++- extensions/whatsapp/src/auth-store.test.ts | 107 +++++++++ extensions/whatsapp/src/auth-store.ts | 92 ++++---- extensions/whatsapp/src/channel.setup.test.ts | 33 +-- extensions/whatsapp/src/creds-files.ts | 91 +++++++- extensions/whatsapp/src/session.test.ts | 209 ++++++++++-------- extensions/whatsapp/src/session.ts | 25 ++- extensions/whatsapp/src/setup-finalize.ts | 6 +- 10 files changed, 428 insertions(+), 180 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 109f41f713c..3e6dd537fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -371,6 +371,7 @@ Docs: https://docs.openclaw.ai - Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827. - Agents/tools: keep the `message` tool available in embedded runs when it is explicitly allowed through `tools.alsoAllow` or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113. - WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef. +- WhatsApp: reject symlinked Web credential files across auth checks and socket startup so unsafe `creds.json` paths cannot be read through. Thanks @mcaxtr. - WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr. - Process/diagnostics: report active lane blockers in lane wait warnings so `queueAhead=0` no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev. - Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings. diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index fea56835d39..aa8302d38c8 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -16,7 +16,7 @@ import { resolveDefaultWhatsAppAccountId, } from "./account-ids.js"; import type { WhatsAppAccountConfig } from "./account-types.js"; -import { hasWebCredsSync } from "./creds-files.js"; +import { hasWebCredsRegularFileSync, hasWebCredsSync } from "./creds-files.js"; export { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId } from "./account-ids.js"; @@ -88,11 +88,7 @@ function resolveLegacyAuthDir(): string { } function legacyAuthExists(authDir: string): boolean { - try { - return fs.existsSync(path.join(authDir, "creds.json")); - } catch { - return false; - } + return hasWebCredsRegularFileSync(authDir); } export function resolveWhatsAppAuthDir(params: { cfg: OpenClawConfig; accountId: string }): { diff --git a/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts index af67c8ea6be..ca70a85fdb4 100644 --- a/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts +++ b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { captureEnv } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js"; +import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs, resolveWhatsAppAuthDir } from "./accounts.js"; describe("hasAnyWhatsAppAuth", () => { let envSnapshot: ReturnType; @@ -37,6 +37,40 @@ describe("hasAnyWhatsAppAuth", () => { expect(hasAnyWhatsAppAuth({})).toBe(true); }); + it.runIf(process.platform !== "win32")("ignores symlinked legacy creds", () => { + const targetPath = path.join(tempOauthDir ?? "", "target-creds.json"); + const credsPath = path.join(tempOauthDir ?? "", "creds.json"); + fs.writeFileSync(targetPath, JSON.stringify({ me: {} })); + fs.symlinkSync(targetPath, credsPath); + + expect(hasAnyWhatsAppAuth({})).toBe(false); + expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({ + authDir: path.join(tempOauthDir ?? "", "whatsapp", "default"), + isLegacy: false, + }); + }); + + it("selects legacy auth when legacy creds are truncated so backup recovery can run", () => { + fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), "{"); + + expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({ + authDir: tempOauthDir, + isLegacy: true, + }); + }); + + it("does not fall back to legacy auth when default creds are truncated", () => { + const defaultAuthDir = path.join(tempOauthDir ?? "", "whatsapp", "default"); + fs.mkdirSync(defaultAuthDir, { recursive: true }); + fs.writeFileSync(path.join(tempOauthDir ?? "", "creds.json"), JSON.stringify({ me: {} })); + fs.writeFileSync(path.join(defaultAuthDir, "creds.json"), "{"); + + expect(resolveWhatsAppAuthDir({ cfg: {}, accountId: "default" })).toEqual({ + authDir: defaultAuthDir, + isLegacy: false, + }); + }); + it("returns true when non-default auth exists", () => { writeCreds(path.join(tempOauthDir ?? "", "whatsapp", "work")); expect(hasAnyWhatsAppAuth({})).toBe(true); diff --git a/extensions/whatsapp/src/auth-store.test.ts b/extensions/whatsapp/src/auth-store.test.ts index db87fe2f6b0..993ccd05db0 100644 --- a/extensions/whatsapp/src/auth-store.test.ts +++ b/extensions/whatsapp/src/auth-store.test.ts @@ -3,10 +3,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + getWebAuthAgeMs, + hasWebCredsSync, logoutWeb, pickWebChannel, + readCredsJsonRaw, readWebAuthSnapshot, readWebAuthState, + readWebSelfId, + readWebSelfIdentity, restoreCredsFromBackupIfNeeded, webAuthExists, WhatsAppAuthUnstableError, @@ -86,6 +91,29 @@ describe("auth-store", () => { }); }); + it("preserves valid large creds instead of treating them as corrupt", async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-large-creds"); + const credsPath = path.join(authDir, "creds.json"); + const largeCreds = JSON.stringify({ + me: { id: "15551234567@s.whatsapp.net" }, + additionalData: "x".repeat(1024 * 1024 + 512), + }); + fsSync.writeFileSync(credsPath, largeCreds, "utf-8"); + fsSync.writeFileSync( + path.join(authDir, "creds.json.bak"), + JSON.stringify({ me: { id: "19990000000@s.whatsapp.net" } }), + "utf-8", + ); + + await expect(webAuthExists(authDir)).resolves.toBe(true); + await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false); + expect(fsSync.readFileSync(credsPath, "utf-8")).toBe(largeCreds); + expect(readWebSelfId(authDir)).toMatchObject({ + e164: "+15551234567", + jid: "15551234567@s.whatsapp.net", + }); + }); + it("refuses to restore creds from a symlinked backup path", async () => { const authDir = createTempAuthDir("openclaw-wa-auth-restore-symlink"); const targetPath = path.join(authDir, "backup-target.json"); @@ -99,6 +127,27 @@ describe("auth-store", () => { expect(fsSync.readFileSync(credsPath, "utf-8")).toBe("{"); }); + it.runIf(process.platform !== "win32")( + "does not restore backup over a symlinked creds path", + async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-restore-target-symlink"); + const targetPath = path.join(authDir, "target-creds.json"); + const credsPath = path.join(authDir, "creds.json"); + const backupPath = path.join(authDir, "creds.json.bak"); + fsSync.writeFileSync(targetPath, "{", "utf-8"); + fsSync.symlinkSync(targetPath, credsPath); + fsSync.writeFileSync( + backupPath, + JSON.stringify({ me: { id: "123@s.whatsapp.net" } }), + "utf-8", + ); + + await expect(restoreCredsFromBackupIfNeeded(authDir)).resolves.toBe(false); + expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true); + expect(fsSync.readFileSync(targetPath, "utf-8")).toBe("{"); + }, + ); + it("reports linked auth state and snapshot from the shared read helper", async () => { const authDir = createTempAuthDir("openclaw-wa-auth-linked"); fsSync.writeFileSync( @@ -122,6 +171,64 @@ describe("auth-store", () => { }); }); + it.runIf(process.platform !== "win32")( + "treats symlinked creds as missing across auth readers", + async () => { + const authDir = createTempAuthDir("openclaw-wa-auth-symlink-read"); + const targetPath = path.join(authDir, "target-creds.json"); + const credsPath = path.join(authDir, "creds.json"); + fsSync.writeFileSync( + targetPath, + JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }), + "utf-8", + ); + fsSync.symlinkSync(targetPath, credsPath); + + expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true); + expect(fsSync.statSync(credsPath).isFile()).toBe(true); + expect(hasWebCredsSync(authDir)).toBe(false); + expect(readCredsJsonRaw(credsPath)).toBeNull(); + expect(getWebAuthAgeMs(authDir)).toBeNull(); + expect(readWebSelfId(authDir)).toEqual({ e164: null, jid: null, lid: null }); + await expect(readWebSelfIdentity(authDir)).resolves.toEqual({ + e164: null, + jid: null, + lid: null, + }); + await expect(webAuthExists(authDir)).resolves.toBe(false); + await expect(readWebAuthState(authDir)).resolves.toBe("not-linked"); + await expect(readWebAuthSnapshot(authDir)).resolves.toEqual({ + state: "not-linked", + authAgeMs: null, + selfId: { e164: null, jid: null, lid: null }, + }); + }, + ); + + it.runIf(process.platform !== "win32")( + "treats creds under a symlinked auth directory as missing", + async () => { + const rootDir = createTempAuthDir("openclaw-wa-auth-symlink-parent"); + const targetAuthDir = path.join(rootDir, "target-auth"); + const authDir = path.join(rootDir, "linked-auth"); + fsSync.mkdirSync(targetAuthDir); + fsSync.writeFileSync( + path.join(targetAuthDir, "creds.json"), + JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }), + "utf-8", + ); + fsSync.symlinkSync(targetAuthDir, authDir, "dir"); + const credsPath = path.join(authDir, "creds.json"); + + expect(fsSync.lstatSync(authDir).isSymbolicLink()).toBe(true); + expect(fsSync.lstatSync(credsPath).isFile()).toBe(true); + expect(hasWebCredsSync(authDir)).toBe(false); + expect(readCredsJsonRaw(credsPath)).toBeNull(); + await expect(webAuthExists(authDir)).resolves.toBe(false); + await expect(readWebAuthState(authDir)).resolves.toBe("not-linked"); + }, + ); + it("reports unstable auth state when the shared barrier read times out", async () => { const authDir = createTempAuthDir("openclaw-wa-auth-unstable-state"); fsSync.writeFileSync( diff --git a/extensions/whatsapp/src/auth-store.ts b/extensions/whatsapp/src/auth-store.ts index 34fdc986fa9..66e818ebbd4 100644 --- a/extensions/whatsapp/src/auth-store.ts +++ b/extensions/whatsapp/src/auth-store.ts @@ -1,4 +1,3 @@ -import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; @@ -8,7 +7,15 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { resolveOAuthDir } from "./auth-store.runtime.js"; -import { hasWebCredsSync, resolveWebCredsBackupPath, resolveWebCredsPath } from "./creds-files.js"; +import { + hasWebCredsSync, + isWebCredsPathRegularFileOrMissing, + readWebCredsJsonRaw, + readWebCredsJsonRawSync, + resolveWebCredsBackupPath, + resolveWebCredsPath, + statWebCredsFileSync, +} from "./creds-files.js"; import { waitForCredsSaveQueueWithTimeout, type CredsQueueWaitResult, @@ -39,18 +46,7 @@ export function resolveDefaultWebAuthDir(): string { export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir(); export function readCredsJsonRaw(filePath: string): string | null { - try { - if (!fsSync.existsSync(filePath)) { - return null; - } - const stats = fsSync.statSync(filePath); - if (!stats.isFile() || stats.size <= 1) { - return null; - } - return fsSync.readFileSync(filePath, "utf-8"); - } catch { - return null; - } + return readWebCredsJsonRawSync(filePath); } async function waitForWebAuthBarrier( @@ -75,6 +71,9 @@ export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise null); - if (!backupStats?.isFile()) { - return false; - } // Ensure backup is parseable before restoring. JSON.parse(backupRaw); @@ -111,17 +106,11 @@ export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise { const resolvedAuthDir = resolveUserPath(authDir); - try { - const raw = await fs.readFile(resolveWebCredsPath(resolvedAuthDir), "utf-8"); - const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined; - return resolveComparableIdentity( - { - jid: parsed?.me?.id ?? null, - lid: parsed?.me?.lid ?? null, - }, - resolvedAuthDir, - ); - } catch { - return resolveComparableIdentity( - { - jid: fallback?.id ?? null, - lid: fallback?.lid ?? null, - }, - resolvedAuthDir, - ); + const raw = await readWebCredsJsonRaw(resolveWebCredsPath(resolvedAuthDir)); + if (raw) { + try { + const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined; + return resolveComparableIdentity( + { + jid: parsed?.me?.id ?? null, + lid: parsed?.me?.lid ?? null, + }, + resolvedAuthDir, + ); + } catch { + // Fall through to the live message identity below when cached creds are corrupt. + } } + return resolveComparableIdentity( + { + jid: fallback?.id ?? null, + lid: fallback?.lid ?? null, + }, + resolvedAuthDir, + ); } export async function readWebSelfIdentityForDecision( @@ -450,12 +442,8 @@ export async function readWebSelfIdentityForDecision( * Helpful for heartbeats/observability to spot stale credentials. */ export function getWebAuthAgeMs(authDir: string = resolveDefaultWebAuthDir()): number | null { - try { - const stats = fsSync.statSync(resolveWebCredsPath(resolveUserPath(authDir))); - return Date.now() - stats.mtimeMs; - } catch { - return null; - } + const stats = statWebCredsFileSync(resolveWebCredsPath(resolveUserPath(authDir))); + return stats ? Math.max(0, Date.now() - stats.mtimeMs) : null; } export function logWebSelfId( diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index cb28c5e6d0a..c2d683871e0 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -25,7 +25,7 @@ import { const hoisted = vi.hoisted(() => ({ loginWeb: vi.fn(async () => {}), - pathExists: vi.fn(async () => false), + hasWebCredsSync: vi.fn(() => false), readWebAuthState: vi.fn(async (): Promise<"linked" | "not-linked" | "unstable"> => "not-linked"), readWebAuthExistsForDecision: vi.fn( async (): Promise<{ outcome: "stable"; exists: boolean } | { outcome: "unstable" }> => ({ @@ -80,7 +80,6 @@ vi.mock("openclaw/plugin-sdk/setup", async () => { return [...normalized]; }, normalizeE164, - pathExists: hoisted.pathExists, splitSetupEntries: splitSetupEntriesForMock, setSetupChannelEnabled: (cfg: OpenClawConfig, channel: string, enabled: boolean) => ({ ...cfg, @@ -95,6 +94,14 @@ vi.mock("openclaw/plugin-sdk/setup", async () => { }; }); +vi.mock("./creds-files.js", async () => { + const actual = await vi.importActual("./creds-files.js"); + return { + ...actual, + hasWebCredsSync: hoisted.hasWebCredsSync, + }; +}); + vi.mock("./accounts.js", async () => { const actual = await vi.importActual("./accounts.js"); return { @@ -146,7 +153,7 @@ function createSeparatePhoneHarness(params: { selectValues: string[]; textValues } async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues?: string[] }) { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createSeparatePhoneHarness({ selectValues: params.selectValues, textValues: params.textValues, @@ -160,8 +167,8 @@ async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues describe("whatsapp setup wizard", () => { beforeEach(() => { hoisted.loginWeb.mockReset(); - hoisted.pathExists.mockReset(); - hoisted.pathExists.mockResolvedValue(false); + hoisted.hasWebCredsSync.mockReset(); + hoisted.hasWebCredsSync.mockReturnValue(false); hoisted.readWebAuthState.mockReset(); hoisted.readWebAuthState.mockResolvedValue("not-linked"); hoisted.readWebAuthExistsForDecision.mockReset(); @@ -233,7 +240,7 @@ describe("whatsapp setup wizard", () => { }); it("enables allowlist self-chat mode for personal-phone setup", async () => { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter); const result = await runConfigureWithHarness({ @@ -244,7 +251,7 @@ describe("whatsapp setup wizard", () => { }); it("throws a user-facing error instead of crashing when personal-phone input is undefined", async () => { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createWhatsAppPersonalPhoneHarness(createQueuedWizardPrompter); harness.text.mockResolvedValueOnce(undefined as never); @@ -256,7 +263,7 @@ describe("whatsapp setup wizard", () => { }); it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createSeparatePhoneHarness({ selectValues: ["separate", "open"], }); @@ -334,7 +341,7 @@ describe("whatsapp setup wizard", () => { }); it("writes default-account DM config into accounts.default for multi-account setups", async () => { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createSeparatePhoneHarness({ selectValues: ["separate", "open"], }); @@ -362,7 +369,7 @@ describe("whatsapp setup wizard", () => { }); it("updates an existing mixed-case default-account key during setup", async () => { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createSeparatePhoneHarness({ selectValues: ["separate", "open"], }); @@ -392,7 +399,7 @@ describe("whatsapp setup wizard", () => { }); it("runs WhatsApp login when not linked and user confirms linking", async () => { - hoisted.pathExists.mockResolvedValue(false); + hoisted.hasWebCredsSync.mockReturnValue(false); const harness = createWhatsAppLinkingHarness(createQueuedWizardPrompter); const runtime = createRuntime(); @@ -405,7 +412,7 @@ describe("whatsapp setup wizard", () => { }); it("skips relink note when already linked and relink is declined", async () => { - hoisted.pathExists.mockResolvedValue(true); + hoisted.hasWebCredsSync.mockReturnValue(true); const harness = createSeparatePhoneHarness({ selectValues: ["separate", "disabled"], }); @@ -419,7 +426,7 @@ describe("whatsapp setup wizard", () => { }); it("shows follow-up login command note when not linked and linking is skipped", async () => { - hoisted.pathExists.mockResolvedValue(false); + hoisted.hasWebCredsSync.mockReturnValue(false); const harness = createSeparatePhoneHarness({ selectValues: ["separate", "disabled"], }); diff --git a/extensions/whatsapp/src/creds-files.ts b/extensions/whatsapp/src/creds-files.ts index 163ed839159..6ec0930971a 100644 --- a/extensions/whatsapp/src/creds-files.ts +++ b/extensions/whatsapp/src/creds-files.ts @@ -1,5 +1,10 @@ -import fsSync from "node:fs"; import path from "node:path"; +import { + assertNoSymlinkParentsSync, + readRegularFile, + readRegularFileSync, + statRegularFileSync, +} from "openclaw/plugin-sdk/security-runtime"; export function resolveWebCredsPath(authDir: string): string { return path.join(authDir, "creds.json"); @@ -9,11 +14,89 @@ export function resolveWebCredsBackupPath(authDir: string): string { return path.join(authDir, "creds.json.bak"); } -export function hasWebCredsSync(authDir: string): boolean { +function assertWebCredsParentPathSafe(filePath: string): void { + const dir = path.resolve(path.dirname(filePath)); + assertNoSymlinkParentsSync({ + rootDir: path.parse(dir).root, + targetPath: dir, + allowMissing: true, + allowRootChildSymlink: true, + requireDirectories: true, + messagePrefix: "WhatsApp credential file path", + }); +} + +export function assertWebCredsPathRegularFileOrMissing(filePath: string): void { try { - const stats = fsSync.statSync(resolveWebCredsPath(authDir)); - return stats.isFile() && stats.size > 1; + assertWebCredsParentPathSafe(filePath); + statRegularFileSync(filePath); + } catch (error) { + throw new Error( + `WhatsApp credential file path is unsafe; creds.json must be a regular file or missing: ${filePath}`, + { cause: error }, + ); + } +} + +export function isWebCredsPathRegularFileOrMissing(filePath: string): boolean { + try { + assertWebCredsPathRegularFileOrMissing(filePath); + return true; } catch { return false; } } + +export function readWebCredsJsonRawSync(filePath: string): string | null { + try { + assertWebCredsParentPathSafe(filePath); + const { buffer, stat } = readRegularFileSync({ + filePath, + }); + return stat.size > 1 ? buffer.toString("utf-8") : null; + } catch { + return null; + } +} + +export async function readWebCredsJsonRaw(filePath: string): Promise { + try { + assertWebCredsParentPathSafe(filePath); + const { buffer, stat } = await readRegularFile({ + filePath, + }); + return stat.size > 1 ? buffer.toString("utf-8") : null; + } catch { + return null; + } +} + +export function statWebCredsFileSync(filePath: string): { mtimeMs: number; size: number } | null { + try { + assertWebCredsParentPathSafe(filePath); + const result = statRegularFileSync(filePath); + if (result.missing || result.stat.size <= 1) { + return null; + } + return { + mtimeMs: result.stat.mtimeMs, + size: result.stat.size, + }; + } catch { + return null; + } +} + +export function hasWebCredsRegularFileSync(authDir: string): boolean { + try { + const credsPath = resolveWebCredsPath(authDir); + assertWebCredsParentPathSafe(credsPath); + return !statRegularFileSync(credsPath).missing; + } catch { + return false; + } +} + +export function hasWebCredsSync(authDir: string): boolean { + return statWebCredsFileSync(resolveWebCredsPath(authDir)) !== null; +} diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 8b605832072..b1c04bc8e80 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -127,60 +127,6 @@ function mockFsOpenForCredsWrites(params?: { }; } -function mockCredsJsonSpies(readContents: string) { - const credsSuffix = path.join("/tmp", "openclaw-oauth", "whatsapp", "default", "creds.json"); - const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {}); - const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { - if (typeof p !== "string") { - return false; - } - return p.endsWith(credsSuffix); - }); - const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => { - if (typeof p === "string" && p.endsWith(credsSuffix)) { - return { isFile: () => true, size: 12 } as never; - } - throw new Error(`unexpected statSync path: ${String(p)}`); - }); - const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { - if (typeof p === "string" && p.endsWith(credsSuffix)) { - return readContents as never; - } - throw new Error(`unexpected readFileSync path: ${String(p)}`); - }); - return { - copySpy, - credsSuffix, - restore: () => { - copySpy.mockRestore(); - existsSpy.mockRestore(); - statSpy.mockRestore(); - readSpy.mockRestore(); - }, - }; -} - -function mockLogWebSelfIdCreds(me: Record) { - const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => { - if (typeof p !== "string") { - return false; - } - return p.endsWith("creds.json"); - }); - const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => { - if (typeof p === "string" && p.endsWith("creds.json")) { - return JSON.stringify({ me }); - } - throw new Error(`unexpected readFileSync path: ${String(p)}`); - }); - return { - restore() { - existsSpy.mockRestore(); - readSpy.mockRestore(); - }, - }; -} - function firstMockCall( mock: { mock: { calls: Array } }, label: string, @@ -321,6 +267,71 @@ describe("web session", () => { openMock.restore(); }); + it.runIf(process.platform !== "win32")( + "rejects symlinked creds before Baileys auth state reads", + async () => { + const authDir = createTempAuthDir("openclaw-wa-creds-symlink-runtime"); + const targetPath = path.join(authDir, "target-creds.json"); + const credsPath = path.join(authDir, "creds.json"); + fsSync.writeFileSync( + targetPath, + JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }), + "utf-8", + ); + fsSync.symlinkSync(targetPath, credsPath); + + await expect(createWaSocket(false, false, { authDir })).rejects.toThrow( + "creds.json must be a regular file or missing", + ); + + expect(useMultiFileAuthStateMock).not.toHaveBeenCalled(); + expect(fsSync.lstatSync(credsPath).isSymbolicLink()).toBe(true); + expect(fsSync.readFileSync(targetPath, "utf-8")).toContain("15551234567"); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects symlinked auth directories before Baileys auth state reads", + async () => { + const rootDir = createTempAuthDir("openclaw-wa-authdir-symlink-runtime"); + const targetAuthDir = path.join(rootDir, "target-auth"); + const authDir = path.join(rootDir, "linked-auth"); + fsSync.mkdirSync(targetAuthDir); + fsSync.writeFileSync( + path.join(targetAuthDir, "creds.json"), + JSON.stringify({ me: { id: "15551234567@s.whatsapp.net" } }), + "utf-8", + ); + fsSync.symlinkSync(targetAuthDir, authDir, "dir"); + + await expect(createWaSocket(false, false, { authDir })).rejects.toThrow( + "creds.json must be a regular file or missing", + ); + + expect(useMultiFileAuthStateMock).not.toHaveBeenCalled(); + expect(fsSync.lstatSync(authDir).isSymbolicLink()).toBe(true); + }, + ); + + it.runIf(process.platform !== "win32")( + "rejects symlinked auth directory parents before creating the auth directory", + async () => { + const rootDir = createTempAuthDir("openclaw-wa-auth-parent-symlink-runtime"); + const targetBaseDir = path.join(rootDir, "target-base"); + const linkedBaseDir = path.join(rootDir, "linked-base"); + const authDir = path.join(linkedBaseDir, "default"); + fsSync.mkdirSync(targetBaseDir); + fsSync.symlinkSync(targetBaseDir, linkedBaseDir, "dir"); + + await expect(createWaSocket(false, false, { authDir })).rejects.toThrow( + "creds.json must be a regular file or missing", + ); + + expect(useMultiFileAuthStateMock).not.toHaveBeenCalled(); + expect(fsSync.existsSync(path.join(targetBaseDir, "default"))).toBe(false); + }, + ); + it("passes explicit Baileys socket timing overrides", async () => { await createWaSocket(false, false, { keepAliveIntervalMs: 10_000, @@ -450,37 +461,47 @@ describe("web session", () => { }); it("logWebSelfId prints cached E.164 when creds exist", () => { - const creds = mockLogWebSelfIdCreds({ id: "12345@s.whatsapp.net" }); + const authDir = createTempAuthDir("openclaw-wa-log-self"); + fsSync.writeFileSync( + path.join(authDir, "creds.json"), + JSON.stringify({ me: { id: "12345@s.whatsapp.net" } }), + "utf-8", + ); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; - logWebSelfId("/tmp/wa-creds", runtime as never, true); + logWebSelfId(authDir, runtime as never, true); expectRuntimeLogContaining(runtime, "Web Channel: +12345 (jid 12345@s.whatsapp.net)"); - creds.restore(); }); it("logWebSelfId prints cached lid details when creds include a lid", () => { - const creds = mockLogWebSelfIdCreds({ - id: "12345@s.whatsapp.net", - lid: "777@lid", - }); + const authDir = createTempAuthDir("openclaw-wa-log-self-lid"); + fsSync.writeFileSync( + path.join(authDir, "creds.json"), + JSON.stringify({ + me: { + id: "12345@s.whatsapp.net", + lid: "777@lid", + }, + }), + "utf-8", + ); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; - logWebSelfId("/tmp/wa-creds", runtime as never, true); + logWebSelfId(authDir, runtime as never, true); expectRuntimeLogContaining( runtime, "Web Channel: +12345 (jid 12345@s.whatsapp.net, lid 777@lid)", ); - creds.restore(); }); it("formatError prints Boom-like payload message", () => { @@ -503,18 +524,20 @@ describe("web session", () => { }); it("does not clobber creds backup when creds.json is corrupted", async () => { - const creds = mockCredsJsonSpies("{"); + const authDir = createTempAuthDir("openclaw-wa-corrupt-backup"); + const backupPath = path.join(authDir, "creds.json.bak"); + fsSync.writeFileSync(path.join(authDir, "creds.json"), "{", "utf-8"); const openMock = mockFsOpenForCredsWrites(); - await createWaSocket(false, false); - await emitCredsUpdate(); - await waitForCredsSaveQueue(); + try { + await createWaSocket(false, false, { authDir }); + await emitCredsUpdate(authDir); - expect(creds.copySpy).not.toHaveBeenCalled(); - expect(openMock.tempHandles).toHaveLength(1); - - creds.restore(); - openMock.restore(); + expect(fsSync.existsSync(backupPath)).toBe(false); + expect(openMock.tempHandles).toHaveLength(1); + } finally { + openMock.restore(); + } }); it("serializes creds.update saves to avoid overlapping writes", async () => { @@ -553,7 +576,7 @@ describe("web session", () => { await waitForCredsSaveQueue(authDir); - expect(openMock.tempHandles).toHaveLength(2); + expect(openMock.tempHandles).toHaveLength(3); expect(maxInFlight).toBe(1); expect(inFlight).toBe(0); openMock.restore(); @@ -612,28 +635,21 @@ describe("web session", () => { }); it("rotates creds backup when creds.json is valid JSON", async () => { - const creds = mockCredsJsonSpies("{}"); + const authDir = createTempAuthDir("openclaw-wa-rotate-backup"); + const credsPath = path.join(authDir, "creds.json"); + const backupPath = path.join(authDir, "creds.json.bak"); + fsSync.writeFileSync(credsPath, "{}", "utf-8"); const openMock = mockFsOpenForCredsWrites(); - const backupSuffix = path.join( - "/tmp", - "openclaw-oauth", - "whatsapp", - "default", - "creds.json.bak", - ); - await createWaSocket(false, false); - await emitCredsUpdate(); - await waitForCredsSaveQueue(); + try { + await createWaSocket(false, false, { authDir }); + await emitCredsUpdate(authDir); - expect(creds.copySpy).toHaveBeenCalledTimes(1); - const [sourcePath, backupPath] = firstMockCall(creds.copySpy, "creds backup copy"); - expect(requireString(sourcePath, "creds backup source path")).toContain(creds.credsSuffix); - expect(requireString(backupPath, "creds backup target path")).toContain(backupSuffix); - expect(openMock.tempHandles).toHaveLength(1); - - creds.restore(); - openMock.restore(); + expect(fsSync.readFileSync(backupPath, "utf-8")).toBe("{}"); + expect(openMock.tempHandles).toHaveLength(2); + } finally { + openMock.restore(); + } }); it("writes creds.json atomically via temp file and rename", async () => { @@ -715,7 +731,14 @@ describe("web session", () => { .readdirSync(authDir) .filter((entry) => entry.startsWith(".creds.") && entry.endsWith(".tmp")); - expect(renameSpy).toHaveBeenCalledOnce(); + const primaryRenameCalls = renameSpy.mock.calls.filter( + ([from, to]) => + typeof from === "string" && + typeof to === "string" && + from.startsWith(path.join(authDir, ".creds.")) && + to === credsPath, + ); + expect(primaryRenameCalls).toHaveLength(1); const parsedCreds = JSON.parse(raw) as unknown; expect(parsedCreds).toEqual(originalCreds); expect(tempEntries).toHaveLength(0); diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index 7f5420a46f9..1b8b2e0d58b 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import fsSync from "node:fs"; import type { Agent } from "node:https"; import { HttpsProxyAgent } from "https-proxy-agent"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; @@ -13,6 +12,7 @@ import { } from "openclaw/plugin-sdk/fetch-runtime"; import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { getChildLogger, toPinoLikeLogger } from "openclaw/plugin-sdk/runtime-env"; +import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { ensureDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; import { readCredsJsonRaw, @@ -21,6 +21,7 @@ import { resolveWebCredsBackupPath, resolveWebCredsPath, } from "./auth-store.js"; +import { assertWebCredsPathRegularFileOrMissing } from "./creds-files.js"; import { enqueueCredsSave, waitForCredsSaveQueueWithTimeout, @@ -71,6 +72,10 @@ const WHATSAPP_WEBSOCKET_PROXY_TARGET = "https://mmg.whatsapp.net/"; const CREDS_FLUSH_TIMEOUT_MESSAGE = "Queued WhatsApp creds save did not finish before auth bootstrap; skipping repair and continuing with primary creds."; +function rejectUnsafeWebCredsPath(authDir: string): void { + assertWebCredsPathRegularFileOrMissing(resolveWebCredsPath(authDir)); +} + function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, @@ -99,12 +104,15 @@ async function safeSaveCreds( if (raw) { try { JSON.parse(raw); - fsSync.copyFileSync(credsPath, backupPath); - try { - fsSync.chmodSync(backupPath, 0o600); - } catch { - // best-effort on platforms that support it - } + await replaceFileAtomic({ + filePath: backupPath, + content: raw, + dirMode: 0o700, + mode: 0o600, + tempPrefix: ".creds.backup", + syncTempFile: true, + syncParentDir: true, + }); } catch { // keep existing backup } @@ -144,14 +152,17 @@ export async function createWaSocket( ); const logger = toPinoLikeLogger(baseLogger, verbose ? "info" : "silent"); const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir()); + rejectUnsafeWebCredsPath(authDir); await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); const queueResult = await waitForCredsSaveQueueWithTimeout(authDir); if (queueResult === "timed_out") { sessionLogger.warn({ authDir }, CREDS_FLUSH_TIMEOUT_MESSAGE); } else { + rejectUnsafeWebCredsPath(authDir); await restoreCredsFromBackupIfNeeded(authDir); } + rejectUnsafeWebCredsPath(authDir); const { state } = await useMultiFileAuthState(authDir); const saveCreds = async () => { await writeCredsJsonAtomically(authDir, state.creds); diff --git a/extensions/whatsapp/src/setup-finalize.ts b/extensions/whatsapp/src/setup-finalize.ts index e2b62eac6d0..9449565ab06 100644 --- a/extensions/whatsapp/src/setup-finalize.ts +++ b/extensions/whatsapp/src/setup-finalize.ts @@ -1,7 +1,5 @@ -import path from "node:path"; import { DEFAULT_ACCOUNT_ID, - pathExists, splitSetupEntries, createSetupTranslator, type DmPolicy, @@ -14,6 +12,7 @@ import { resolveWhatsAppAccount, resolveWhatsAppAuthDir, } from "./accounts.js"; +import { hasWebCredsSync } from "./creds-files.js"; import { normalizeWhatsAppAllowFromEntries, normalizeWhatsAppAllowFromEntry, @@ -159,8 +158,7 @@ function setWhatsAppSelfChatMode( async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise { const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const credsPath = path.join(authDir, "creds.json"); - return await pathExists(credsPath); + return hasWebCredsSync(authDir); } async function promptWhatsAppOwnerAllowFrom(params: {