fix(security): lock down inbound DMs by default

This commit is contained in:
Peter Steinberger
2026-01-06 17:51:38 +01:00
parent 327ad3c9c7
commit 967cef80bc
36 changed files with 2093 additions and 203 deletions

View File

@@ -7,6 +7,8 @@ const stopMock = vi.fn();
const sendMock = vi.fn();
const replyMock = vi.fn();
const updateLastRouteMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
let config: Record<string, unknown> = {};
let notificationHandler:
@@ -30,6 +32,13 @@ vi.mock("./send.js", () => ({
sendMessageIMessage: (...args: unknown[]) => sendMock(...args),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readProviderAllowFromStore: (...args: unknown[]) =>
readAllowFromStoreMock(...args),
upsertProviderPairingRequest: (...args: unknown[]) =>
upsertPairingRequestMock(...args),
}));
vi.mock("../config/sessions.js", () => ({
resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
@@ -63,7 +72,11 @@ async function waitForSubscribe() {
beforeEach(() => {
config = {
imessage: { groups: { "*": { requireMention: true } } },
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: true } },
},
session: { mainKey: "main" },
routing: {
groupChat: { mentionPatterns: ["@clawd"] },
@@ -79,6 +92,10 @@ beforeEach(() => {
sendMock.mockReset().mockResolvedValue({ messageId: "ok" });
replyMock.mockReset().mockResolvedValue({ text: "ok" });
updateLastRouteMock.mockReset();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock
.mockReset()
.mockResolvedValue({ code: "PAIRCODE", created: true });
notificationHandler = undefined;
closeResolve = undefined;
});
@@ -234,6 +251,44 @@ describe("monitorIMessageProvider", () => {
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("defaults to dmPolicy=pairing behavior when allowFrom is empty", async () => {
config = {
...config,
imessage: {
dmPolicy: "pairing",
allowFrom: [],
groups: { "*": { requireMention: true } },
},
};
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
method: "message",
params: {
message: {
id: 99,
chat_id: 77,
sender: "+15550001111",
is_from_me: false,
text: "hello",
is_group: false,
},
},
});
await flush();
closeResolve?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
});
it("delivers group replies when mentioned", async () => {
replyMock.mockResolvedValueOnce({ text: "yo" });
const run = monitorIMessageProvider();

View File

@@ -16,6 +16,10 @@ import {
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { mediaKindFromMime } from "../media/constants.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { createIMessageRpcClient } from "./client.js";
import { sendMessageIMessage } from "./send.js";
@@ -130,6 +134,7 @@ export async function monitorIMessageProvider(
const allowFrom = resolveAllowFrom(opts);
const groupAllowFrom = resolveGroupAllowFrom(opts);
const groupPolicy = cfg.imessage?.groupPolicy ?? "open";
const dmPolicy = cfg.imessage?.dmPolicy ?? "pairing";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
@@ -153,20 +158,34 @@ export async function monitorIMessageProvider(
if (isGroup && !chatId) return;
const groupId = isGroup ? String(chatId) : undefined;
const storeAllowFrom = await readProviderAllowFromStore("imessage").catch(
() => [],
);
const effectiveDmAllowFrom = Array.from(
new Set([...allowFrom, ...storeAllowFrom]),
)
.map((v) => String(v).trim())
.filter(Boolean);
const effectiveGroupAllowFrom = Array.from(
new Set([...groupAllowFrom, ...storeAllowFrom]),
)
.map((v) => String(v).trim())
.filter(Boolean);
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose("Blocked iMessage group message (groupPolicy: disabled)");
return;
}
if (groupPolicy === "allowlist") {
if (groupAllowFrom.length === 0) {
if (effectiveGroupAllowFrom.length === 0) {
logVerbose(
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
);
return;
}
const allowed = isAllowedIMessageSender({
allowFrom: groupAllowFrom,
allowFrom: effectiveGroupAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
@@ -192,16 +211,64 @@ export async function monitorIMessageProvider(
}
}
const dmAuthorized = isAllowedIMessageSender({
allowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
});
if (!isGroup && !dmAuthorized) {
logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`);
return;
const dmHasWildcard = effectiveDmAllowFrom.includes("*");
const dmAuthorized =
dmPolicy === "open"
? true
: dmHasWildcard ||
(effectiveDmAllowFrom.length > 0 &&
isAllowedIMessageSender({
allowFrom: effectiveDmAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
}));
if (!isGroup) {
if (dmPolicy === "disabled") return;
if (!dmAuthorized) {
if (dmPolicy === "pairing") {
const senderId = normalizeIMessageHandle(sender);
const { code } = await upsertProviderPairingRequest({
provider: "imessage",
id: senderId,
meta: {
sender: senderId,
chatId: chatId ? String(chatId) : undefined,
},
});
logVerbose(
`imessage pairing request sender=${senderId} code=${code}`,
);
try {
await sendMessageIMessage(
sender,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider imessage <code>",
].join("\n"),
{
client,
maxBytes: mediaMaxBytes,
...(chatId ? { chatId } : {}),
},
);
} catch (err) {
logVerbose(
`imessage pairing reply failed for ${senderId}: ${String(err)}`,
);
}
} else {
logVerbose(
`Blocked iMessage sender ${sender} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
const messageText = (message.text ?? "").trim();
@@ -217,9 +284,9 @@ export async function monitorIMessageProvider(
});
const canDetectMention = mentionRegexes.length > 0;
const commandAuthorized = isGroup
? groupAllowFrom.length > 0
? effectiveGroupAllowFrom.length > 0
? isAllowedIMessageSender({
allowFrom: groupAllowFrom,
allowFrom: effectiveGroupAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,