From ff63204d17abdf1b05349835e2d83533d3eea705 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 21 Dec 2025 13:58:31 +0000 Subject: [PATCH] fix(web): harden WhatsApp creds persistence --- src/web/session.test.ts | 127 ++++++++++++++++++++++++++++++++++++++++ src/web/session.ts | 25 +++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/src/web/session.test.ts b/src/web/session.test.ts index e595742ba..6aa2e91d3 100644 --- a/src/web/session.test.ts +++ b/src/web/session.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import fsSync from "node:fs"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { @@ -43,6 +44,7 @@ describe("web session", () => { ).saveCreds; // trigger creds.update listener sock.ev.emit("creds.update", {}); + await new Promise((resolve) => setImmediate(resolve)); expect(saveCreds).toHaveBeenCalled(); }); @@ -107,4 +109,129 @@ describe("web session", () => { expect(formatError(err)).toContain("Request Time-out"); expect(formatError(err)).toContain("QR refs attempts ended"); }); + + it("does not clobber creds backup when creds.json is corrupted", async () => { + const credsSuffix = path.join(".clawdis", "credentials", "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 "{" as never; + } + throw new Error(`unexpected readFileSync path: ${String(p)}`); + }); + + await createWaSocket(false, false); + const sock = getLastSocket(); + const saveCreds = ( + await baileys.useMultiFileAuthState.mock.results[0].value + ).saveCreds; + + sock.ev.emit("creds.update", {}); + await new Promise((resolve) => setImmediate(resolve)); + + expect(copySpy).not.toHaveBeenCalled(); + expect(saveCreds).toHaveBeenCalled(); + + copySpy.mockRestore(); + existsSpy.mockRestore(); + statSpy.mockRestore(); + readSpy.mockRestore(); + }); + + it("serializes creds.update saves to avoid overlapping writes", async () => { + let inFlight = 0; + let maxInFlight = 0; + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const saveCreds = vi.fn(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await gate; + inFlight -= 1; + }); + baileys.useMultiFileAuthState.mockResolvedValueOnce({ + state: { creds: {}, keys: {} }, + saveCreds, + }); + + await createWaSocket(false, false); + const sock = getLastSocket(); + + sock.ev.emit("creds.update", {}); + sock.ev.emit("creds.update", {}); + + await new Promise((resolve) => setImmediate(resolve)); + expect(inFlight).toBe(1); + + release?.(); + + // let both queued saves complete + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + expect(saveCreds).toHaveBeenCalledTimes(2); + expect(maxInFlight).toBe(1); + expect(inFlight).toBe(0); + }); + + it("rotates creds backup when creds.json is valid JSON", async () => { + const credsSuffix = path.join(".clawdis", "credentials", "creds.json"); + const backupSuffix = path.join(".clawdis", "credentials", "creds.json.bak"); + + 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 "{}" as never; + } + throw new Error(`unexpected readFileSync path: ${String(p)}`); + }); + + await createWaSocket(false, false); + const sock = getLastSocket(); + const saveCreds = ( + await baileys.useMultiFileAuthState.mock.results[0].value + ).saveCreds; + + sock.ev.emit("creds.update", {}); + await new Promise((resolve) => setImmediate(resolve)); + + expect(copySpy).toHaveBeenCalledTimes(1); + const args = copySpy.mock.calls[0] ?? []; + expect(String(args[0] ?? "")).toContain(credsSuffix); + expect(String(args[1] ?? "")).toContain(backupSuffix); + expect(saveCreds).toHaveBeenCalled(); + + copySpy.mockRestore(); + existsSpy.mockRestore(); + statSpy.mockRestore(); + readSpy.mockRestore(); + }); }); diff --git a/src/web/session.ts b/src/web/session.ts index 79462e1ff..947856e3c 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -23,6 +23,18 @@ export const WA_WEB_AUTH_DIR = path.join(CONFIG_DIR, "credentials"); const WA_CREDS_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json"); const WA_CREDS_BACKUP_PATH = path.join(WA_WEB_AUTH_DIR, "creds.json.bak"); +let credsSaveQueue: Promise = Promise.resolve(); +function enqueueSaveCreds( + saveCreds: () => Promise | void, + logger: ReturnType, +): void { + credsSaveQueue = credsSaveQueue + .then(() => safeSaveCreds(saveCreds, logger)) + .catch((err) => { + logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); + }); +} + function readCredsJsonRaw(filePath: string): string | null { try { if (!fsSync.existsSync(filePath)) return null; @@ -66,8 +78,15 @@ async function safeSaveCreds( ): Promise { try { // Best-effort backup so we can recover after abrupt restarts. - if (fsSync.existsSync(WA_CREDS_PATH)) { - fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH); + // Important: don't clobber a good backup with a corrupted/truncated creds.json. + const raw = readCredsJsonRaw(WA_CREDS_PATH); + if (raw) { + try { + JSON.parse(raw); + fsSync.copyFileSync(WA_CREDS_PATH, WA_CREDS_BACKUP_PATH); + } catch { + // keep existing backup + } } } catch { // ignore backup failures @@ -113,7 +132,7 @@ export async function createWaSocket( markOnlineOnConnect: false, }); - sock.ev.on("creds.update", () => safeSaveCreds(saveCreds, sessionLogger)); + sock.ev.on("creds.update", () => enqueueSaveCreds(saveCreds, sessionLogger)); sock.ev.on( "connection.update", (update: Partial) => {