fix(security): lock down inbound DMs by default
This commit is contained in:
@@ -93,6 +93,18 @@ vi.mock("../daemon/service.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../telegram/pairing-store.js", () => ({
|
||||
readTelegramAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../pairing/pairing-store.js", () => ({
|
||||
readProviderAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../telegram/token.js", () => ({
|
||||
resolveTelegramToken: vi.fn(() => ({ token: "", source: "none" })),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: {
|
||||
log: () => {},
|
||||
|
||||
@@ -27,10 +27,13 @@ import {
|
||||
} from "../daemon/legacy.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
|
||||
import { resolveTelegramToken } from "../telegram/token.js";
|
||||
import { normalizeE164, resolveUserPath, sleep } from "../utils.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
@@ -50,6 +53,196 @@ function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string {
|
||||
return path.join(os.homedir(), ".clawdis", "clawdis.json");
|
||||
}
|
||||
|
||||
async function noteSecurityWarnings(cfg: ClawdbotConfig) {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const warnDmPolicy = async (params: {
|
||||
label: string;
|
||||
provider:
|
||||
| "telegram"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "whatsapp";
|
||||
dmPolicy: string;
|
||||
allowFrom?: Array<string | number> | null;
|
||||
allowFromPath: string;
|
||||
approveHint: string;
|
||||
normalizeEntry?: (raw: string) => string;
|
||||
}) => {
|
||||
const dmPolicy = params.dmPolicy;
|
||||
const configAllowFrom = (params.allowFrom ?? []).map((v) =>
|
||||
String(v).trim(),
|
||||
);
|
||||
const hasWildcard = configAllowFrom.includes("*");
|
||||
const storeAllowFrom = await readProviderAllowFromStore(
|
||||
params.provider,
|
||||
).catch(() => []);
|
||||
const normalizedCfg = configAllowFrom
|
||||
.filter((v) => v !== "*")
|
||||
.map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
const normalizedStore = storeAllowFrom
|
||||
.map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
const allowCount = Array.from(
|
||||
new Set([...normalizedCfg, ...normalizedStore]),
|
||||
).length;
|
||||
|
||||
if (dmPolicy === "open") {
|
||||
const policyPath = `${params.allowFromPath}policy`;
|
||||
const allowFromPath = `${params.allowFromPath}allowFrom`;
|
||||
warnings.push(
|
||||
`- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`,
|
||||
);
|
||||
if (!hasWildcard) {
|
||||
warnings.push(
|
||||
`- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy === "disabled") {
|
||||
const policyPath = `${params.allowFromPath}policy`;
|
||||
warnings.push(
|
||||
`- ${params.label} DMs: disabled (${policyPath}="disabled").`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowCount === 0) {
|
||||
const policyPath = `${params.allowFromPath}policy`;
|
||||
warnings.push(
|
||||
`- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
|
||||
);
|
||||
warnings.push(` ${params.approveHint}`);
|
||||
}
|
||||
};
|
||||
|
||||
const telegramConfigured = Boolean(cfg.telegram);
|
||||
const { token: telegramToken } = resolveTelegramToken(cfg);
|
||||
if (telegramConfigured && telegramToken.trim()) {
|
||||
const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) =>
|
||||
String(v).trim(),
|
||||
);
|
||||
const hasWildcard = configAllowFrom.includes("*");
|
||||
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
|
||||
const allowCount = Array.from(
|
||||
new Set([
|
||||
...configAllowFrom
|
||||
.filter((v) => v !== "*")
|
||||
.map((v) => v.replace(/^(telegram|tg):/i, ""))
|
||||
.filter(Boolean),
|
||||
...storeAllowFrom.filter((v) => v !== "*"),
|
||||
]),
|
||||
).length;
|
||||
|
||||
if (dmPolicy === "open") {
|
||||
warnings.push(
|
||||
`- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`,
|
||||
);
|
||||
if (!hasWildcard) {
|
||||
warnings.push(
|
||||
`- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`,
|
||||
);
|
||||
}
|
||||
} else if (dmPolicy === "disabled") {
|
||||
warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`);
|
||||
} else if (allowCount === 0) {
|
||||
warnings.push(
|
||||
`- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
|
||||
);
|
||||
warnings.push(
|
||||
` Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve <code>`,
|
||||
);
|
||||
}
|
||||
|
||||
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
|
||||
const groupAllowlistConfigured =
|
||||
cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0;
|
||||
if (groupPolicy === "open" && !groupAllowlistConfigured) {
|
||||
warnings.push(
|
||||
`- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cfg.discord?.enabled !== false) {
|
||||
await warnDmPolicy({
|
||||
label: "Discord",
|
||||
provider: "discord",
|
||||
dmPolicy: cfg.discord?.dm?.policy ?? "pairing",
|
||||
allowFrom: cfg.discord?.dm?.allowFrom ?? [],
|
||||
allowFromPath: "discord.dm.",
|
||||
approveHint:
|
||||
"Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord <code>",
|
||||
normalizeEntry: (raw) =>
|
||||
raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.slack?.enabled !== false) {
|
||||
await warnDmPolicy({
|
||||
label: "Slack",
|
||||
provider: "slack",
|
||||
dmPolicy: cfg.slack?.dm?.policy ?? "pairing",
|
||||
allowFrom: cfg.slack?.dm?.allowFrom ?? [],
|
||||
allowFromPath: "slack.dm.",
|
||||
approveHint:
|
||||
"Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack <code>",
|
||||
normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.signal?.enabled !== false) {
|
||||
await warnDmPolicy({
|
||||
label: "Signal",
|
||||
provider: "signal",
|
||||
dmPolicy: cfg.signal?.dmPolicy ?? "pairing",
|
||||
allowFrom: cfg.signal?.allowFrom ?? [],
|
||||
allowFromPath: "signal.",
|
||||
approveHint:
|
||||
"Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal <code>",
|
||||
normalizeEntry: (raw) =>
|
||||
normalizeE164(raw.replace(/^signal:/i, "").trim()),
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.imessage?.enabled !== false) {
|
||||
await warnDmPolicy({
|
||||
label: "iMessage",
|
||||
provider: "imessage",
|
||||
dmPolicy: cfg.imessage?.dmPolicy ?? "pairing",
|
||||
allowFrom: cfg.imessage?.allowFrom ?? [],
|
||||
allowFromPath: "imessage.",
|
||||
approveHint:
|
||||
"Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage <code>",
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.whatsapp) {
|
||||
await warnDmPolicy({
|
||||
label: "WhatsApp",
|
||||
provider: "whatsapp",
|
||||
dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing",
|
||||
allowFrom: cfg.whatsapp?.allowFrom ?? [],
|
||||
allowFromPath: "whatsapp.",
|
||||
approveHint:
|
||||
"Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp <code>",
|
||||
normalizeEntry: (raw) => normalizeE164(raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
note(warnings.join("\n"), "Security");
|
||||
}
|
||||
}
|
||||
|
||||
function replacePathSegment(
|
||||
value: string | undefined,
|
||||
from: string,
|
||||
@@ -645,6 +838,8 @@ export async function doctorCommand(
|
||||
|
||||
await maybeMigrateLegacyGatewayService(cfg, runtime);
|
||||
|
||||
await noteSecurityWarnings(cfg);
|
||||
|
||||
if (process.platform === "linux" && resolveMode(cfg) === "local") {
|
||||
const service = resolveGatewayService();
|
||||
let loaded = false;
|
||||
|
||||
Reference in New Issue
Block a user