fix: reject symlinked whatsapp creds

This commit is contained in:
Marcus Castro
2026-05-17 02:05:18 -03:00
committed by Peter Steinberger
parent 8284c035a0
commit 194f0786d4
10 changed files with 428 additions and 180 deletions

View File

@@ -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.

View File

@@ -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 }): {

View File

@@ -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<typeof captureEnv>;
@@ -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);

View File

@@ -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(

View File

@@ -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<b
try {
const credsPath = resolveWebCredsPath(authDir);
const backupPath = resolveWebCredsBackupPath(authDir);
if (!isWebCredsPathRegularFileOrMissing(credsPath)) {
return false;
}
const raw = readCredsJsonRaw(credsPath);
if (raw) {
// Validate that creds.json is parseable.
@@ -86,10 +85,6 @@ export async function restoreCredsFromBackupIfNeeded(authDir: string): Promise<b
if (!backupRaw) {
return false;
}
const backupStats = await fs.lstat(backupPath).catch(() => 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<b
export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()) {
const resolvedAuthDir = resolveUserPath(authDir);
const credsPath = resolveWebCredsPath(resolvedAuthDir);
try {
await fs.access(resolvedAuthDir);
} catch {
const raw = await readWebCredsJsonRaw(credsPath);
if (!raw) {
return false;
}
try {
const stats = await fs.stat(credsPath);
if (!stats.isFile() || stats.size <= 1) {
return false;
}
const raw = await fs.readFile(credsPath, "utf-8");
JSON.parse(raw);
return true;
} catch {
@@ -382,10 +371,10 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
try {
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
if (!fsSync.existsSync(credsPath)) {
const raw = readCredsJsonRaw(credsPath);
if (!raw) {
return emptyWebSelfId();
}
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string; lid?: string } } | undefined;
const identity = resolveComparableIdentity(
{
@@ -409,25 +398,28 @@ export async function readWebSelfIdentity(
fallback?: { id?: string | null; lid?: string | null } | null,
): Promise<WhatsAppSelfIdentity> {
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(

View File

@@ -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<typeof import("./creds-files.js")>("./creds-files.js");
return {
...actual,
hasWebCredsSync: hoisted.hasWebCredsSync,
};
});
vi.mock("./accounts.js", async () => {
const actual = await vi.importActual<typeof import("./accounts.js")>("./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"],
});

View File

@@ -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<string | null> {
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;
}

View File

@@ -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<string, string>) {
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<readonly unknown[]> } },
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);

View File

@@ -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> | 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);

View File

@@ -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<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
return hasWebCredsSync(authDir);
}
async function promptWhatsAppOwnerAllowFrom(params: {