fix(security): lock down inbound DMs by default
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user