From 1bdd9e313fa3cd0bce3d5b2732827876561b2a81 Mon Sep 17 00:00:00 2001 From: Leszek Szpunar <13106764+leszekszpunar@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:29:53 +0100 Subject: [PATCH] security(web): sanitize WhatsApp accountId to prevent path traversal (#4610) * security(web): sanitize WhatsApp accountId to prevent path traversal Apply normalizeAccountId() from routing/session-key to resolveDefaultAuthDir() so that malicious config values like "../../../etc" cannot escape the intended auth directory. Fixes #2692 * fix(web): check sanitized segment instead of full path in Windows test * style(web): fix oxfmt formatting in accounts test --- src/web/accounts.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++ src/web/accounts.ts | 4 ++-- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/web/accounts.test.ts diff --git a/src/web/accounts.test.ts b/src/web/accounts.test.ts new file mode 100644 index 0000000000..c21c253bd9 --- /dev/null +++ b/src/web/accounts.test.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveWhatsAppAuthDir } from "./accounts.js"; + +describe("resolveWhatsAppAuthDir", () => { + const stubCfg = { channels: { whatsapp: { accounts: {} } } } as Parameters< + typeof resolveWhatsAppAuthDir + >[0]["cfg"]; + + it("sanitizes path traversal sequences in accountId", () => { + const { authDir } = resolveWhatsAppAuthDir({ + cfg: stubCfg, + accountId: "../../../etc/passwd", + }); + // Sanitized accountId must not escape the whatsapp auth directory. + expect(authDir).not.toContain(".."); + expect(path.basename(authDir)).not.toContain("/"); + }); + + it("sanitizes special characters in accountId", () => { + const { authDir } = resolveWhatsAppAuthDir({ + cfg: stubCfg, + accountId: "foo/bar\\baz", + }); + // Sprawdzaj sanityzacje na segmencie accountId, nie na calej sciezce + // (Windows uzywa backslash jako separator katalogow). + const segment = path.basename(authDir); + expect(segment).not.toContain("/"); + expect(segment).not.toContain("\\"); + }); + + it("returns default directory for empty accountId", () => { + const { authDir } = resolveWhatsAppAuthDir({ + cfg: stubCfg, + accountId: "", + }); + expect(authDir).toMatch(/whatsapp[/\\]default$/); + }); + + it("preserves valid accountId unchanged", () => { + const { authDir } = resolveWhatsAppAuthDir({ + cfg: stubCfg, + accountId: "my-account-1", + }); + expect(authDir).toMatch(/whatsapp[/\\]my-account-1$/); + }); +}); diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 88754b2a4d..fd0dab05d4 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -3,7 +3,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "../config/types.js"; import { resolveOAuthDir } from "../config/paths.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveUserPath } from "../utils.js"; import { hasWebCredsSync } from "./auth-store.js"; @@ -95,7 +95,7 @@ function resolveAccountConfig( } function resolveDefaultAuthDir(accountId: string): string { - return path.join(resolveOAuthDir(), "whatsapp", accountId); + return path.join(resolveOAuthDir(), "whatsapp", normalizeAccountId(accountId)); } function resolveLegacyAuthDir(): string {