add sender allowlist for per-chat access control (#705)
* feat: add sender allowlist for per-chat access control * style: fix prettier formatting
This commit is contained in:
@@ -27,6 +27,12 @@ export const MOUNT_ALLOWLIST_PATH = path.join(
|
||||
'nanoclaw',
|
||||
'mount-allowlist.json',
|
||||
);
|
||||
export const SENDER_ALLOWLIST_PATH = path.join(
|
||||
HOME_DIR,
|
||||
'.config',
|
||||
'nanoclaw',
|
||||
'sender-allowlist.json',
|
||||
);
|
||||
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
|
||||
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
|
||||
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
|
||||
|
||||
@@ -313,7 +313,7 @@ export function getNewMessages(
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
||||
FROM messages
|
||||
WHERE timestamp > ? AND chat_jid IN (${placeholders})
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
@@ -341,7 +341,7 @@ export function getMessagesSince(
|
||||
// Filter bot messages using both the is_bot_message flag AND the content
|
||||
// prefix as a backstop for messages written before the migration ran.
|
||||
const sql = `
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp
|
||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
|
||||
FROM messages
|
||||
WHERE chat_jid = ? AND timestamp > ?
|
||||
AND is_bot_message = 0 AND content NOT LIKE ?
|
||||
|
||||
45
src/index.ts
45
src/index.ts
@@ -41,6 +41,12 @@ import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import {
|
||||
isSenderAllowed,
|
||||
isTriggerAllowed,
|
||||
loadSenderAllowlist,
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -155,8 +161,11 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
||||
|
||||
// For non-main groups, check if trigger is required and present
|
||||
if (!isMainGroup && group.requiresTrigger !== false) {
|
||||
const hasTrigger = missedMessages.some((m) =>
|
||||
TRIGGER_PATTERN.test(m.content.trim()),
|
||||
const allowlistCfg = loadSenderAllowlist();
|
||||
const hasTrigger = missedMessages.some(
|
||||
(m) =>
|
||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
||||
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
if (!hasTrigger) return true;
|
||||
}
|
||||
@@ -380,8 +389,12 @@ async function startMessageLoop(): Promise<void> {
|
||||
// Non-trigger messages accumulate in DB and get pulled as
|
||||
// context when a trigger eventually arrives.
|
||||
if (needsTrigger) {
|
||||
const hasTrigger = groupMessages.some((m) =>
|
||||
TRIGGER_PATTERN.test(m.content.trim()),
|
||||
const allowlistCfg = loadSenderAllowlist();
|
||||
const hasTrigger = groupMessages.some(
|
||||
(m) =>
|
||||
TRIGGER_PATTERN.test(m.content.trim()) &&
|
||||
(m.is_from_me ||
|
||||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
||||
);
|
||||
if (!hasTrigger) continue;
|
||||
}
|
||||
@@ -465,7 +478,29 @@ async function main(): Promise<void> {
|
||||
|
||||
// Channel callbacks (shared by all channels)
|
||||
const channelOpts = {
|
||||
onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
|
||||
onMessage: (_chatJid: string, msg: NewMessage) => {
|
||||
// Sender allowlist drop mode: discard messages from denied senders before storing
|
||||
if (
|
||||
!msg.is_from_me &&
|
||||
!msg.is_bot_message &&
|
||||
registeredGroups[_chatJid]
|
||||
) {
|
||||
const cfg = loadSenderAllowlist();
|
||||
if (
|
||||
shouldDropMessage(_chatJid, cfg) &&
|
||||
!isSenderAllowed(_chatJid, msg.sender, cfg)
|
||||
) {
|
||||
if (cfg.logDenied) {
|
||||
logger.debug(
|
||||
{ chatJid: _chatJid, sender: msg.sender },
|
||||
'sender-allowlist: dropping message (drop mode)',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
storeMessage(msg);
|
||||
},
|
||||
onChatMetadata: (
|
||||
chatJid: string,
|
||||
timestamp: string,
|
||||
|
||||
216
src/sender-allowlist.test.ts
Normal file
216
src/sender-allowlist.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isSenderAllowed,
|
||||
isTriggerAllowed,
|
||||
loadSenderAllowlist,
|
||||
SenderAllowlistConfig,
|
||||
shouldDropMessage,
|
||||
} from './sender-allowlist.js';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function cfgPath(name = 'sender-allowlist.json'): string {
|
||||
return path.join(tmpDir, name);
|
||||
}
|
||||
|
||||
function writeConfig(config: unknown, name?: string): string {
|
||||
const p = cfgPath(name);
|
||||
fs.writeFileSync(p, JSON.stringify(config));
|
||||
return p;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('loadSenderAllowlist', () => {
|
||||
it('returns allow-all defaults when file is missing', () => {
|
||||
const cfg = loadSenderAllowlist(cfgPath());
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
expect(cfg.default.mode).toBe('trigger');
|
||||
expect(cfg.logDenied).toBe(true);
|
||||
});
|
||||
|
||||
it('loads allow=* config', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: false,
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
expect(cfg.logDenied).toBe(false);
|
||||
});
|
||||
|
||||
it('loads allow=[] (deny all)', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: [], mode: 'trigger' },
|
||||
chats: {},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads allow=[list]', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: ['alice', 'bob'], mode: 'drop' },
|
||||
chats: {},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toEqual(['alice', 'bob']);
|
||||
expect(cfg.default.mode).toBe('drop');
|
||||
});
|
||||
|
||||
it('per-chat override beats default', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: { 'group-a': { allow: ['alice'], mode: 'drop' } },
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.chats['group-a'].allow).toEqual(['alice']);
|
||||
expect(cfg.chats['group-a'].mode).toBe('drop');
|
||||
});
|
||||
|
||||
it('returns allow-all on invalid JSON', () => {
|
||||
const p = cfgPath();
|
||||
fs.writeFileSync(p, '{ not valid json }}}');
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
});
|
||||
|
||||
it('returns allow-all on invalid schema', () => {
|
||||
const p = writeConfig({ default: { oops: true } });
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*');
|
||||
});
|
||||
|
||||
it('rejects non-string allow array items', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: [123, null, true], mode: 'trigger' },
|
||||
chats: {},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.default.allow).toBe('*'); // falls back to default
|
||||
});
|
||||
|
||||
it('skips invalid per-chat entries', () => {
|
||||
const p = writeConfig({
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {
|
||||
good: { allow: ['alice'], mode: 'trigger' },
|
||||
bad: { allow: 123 },
|
||||
},
|
||||
});
|
||||
const cfg = loadSenderAllowlist(p);
|
||||
expect(cfg.chats['good']).toBeDefined();
|
||||
expect(cfg.chats['bad']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSenderAllowed', () => {
|
||||
it('allow=* allows any sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('allow=[] denies any sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: [], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('allow=[list] allows exact match only', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice', 'bob'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true);
|
||||
expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses per-chat entry over default', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: { g1: { allow: ['alice'], mode: 'trigger' } },
|
||||
logDenied: true,
|
||||
};
|
||||
expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false);
|
||||
expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldDropMessage', () => {
|
||||
it('returns false for trigger mode', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(shouldDropMessage('g1', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for drop mode', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'drop' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
expect(shouldDropMessage('g1', cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('per-chat mode override', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: { g1: { allow: '*', mode: 'drop' } },
|
||||
logDenied: true,
|
||||
};
|
||||
expect(shouldDropMessage('g1', cfg)).toBe(true);
|
||||
expect(shouldDropMessage('g2', cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTriggerAllowed', () => {
|
||||
it('allows trigger for allowed sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: false,
|
||||
};
|
||||
expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies trigger for disallowed sender', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: false,
|
||||
};
|
||||
expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('logs when logDenied is true', () => {
|
||||
const cfg: SenderAllowlistConfig = {
|
||||
default: { allow: ['alice'], mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
isTriggerAllowed('g1', 'eve', cfg);
|
||||
// Logger.debug is called — we just verify no crash; logger is a real pino instance
|
||||
});
|
||||
});
|
||||
128
src/sender-allowlist.ts
Normal file
128
src/sender-allowlist.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import { SENDER_ALLOWLIST_PATH } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export interface ChatAllowlistEntry {
|
||||
allow: '*' | string[];
|
||||
mode: 'trigger' | 'drop';
|
||||
}
|
||||
|
||||
export interface SenderAllowlistConfig {
|
||||
default: ChatAllowlistEntry;
|
||||
chats: Record<string, ChatAllowlistEntry>;
|
||||
logDenied: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SenderAllowlistConfig = {
|
||||
default: { allow: '*', mode: 'trigger' },
|
||||
chats: {},
|
||||
logDenied: true,
|
||||
};
|
||||
|
||||
function isValidEntry(entry: unknown): entry is ChatAllowlistEntry {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const validAllow =
|
||||
e.allow === '*' ||
|
||||
(Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string'));
|
||||
const validMode = e.mode === 'trigger' || e.mode === 'drop';
|
||||
return validAllow && validMode;
|
||||
}
|
||||
|
||||
export function loadSenderAllowlist(
|
||||
pathOverride?: string,
|
||||
): SenderAllowlistConfig {
|
||||
const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, 'utf-8');
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG;
|
||||
logger.warn(
|
||||
{ err, path: filePath },
|
||||
'sender-allowlist: cannot read config',
|
||||
);
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON');
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
if (!isValidEntry(obj.default)) {
|
||||
logger.warn(
|
||||
{ path: filePath },
|
||||
'sender-allowlist: invalid or missing default entry',
|
||||
);
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
const chats: Record<string, ChatAllowlistEntry> = {};
|
||||
if (obj.chats && typeof obj.chats === 'object') {
|
||||
for (const [jid, entry] of Object.entries(
|
||||
obj.chats as Record<string, unknown>,
|
||||
)) {
|
||||
if (isValidEntry(entry)) {
|
||||
chats[jid] = entry;
|
||||
} else {
|
||||
logger.warn(
|
||||
{ jid, path: filePath },
|
||||
'sender-allowlist: skipping invalid chat entry',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
default: obj.default as ChatAllowlistEntry,
|
||||
chats,
|
||||
logDenied: obj.logDenied !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function getEntry(
|
||||
chatJid: string,
|
||||
cfg: SenderAllowlistConfig,
|
||||
): ChatAllowlistEntry {
|
||||
return cfg.chats[chatJid] ?? cfg.default;
|
||||
}
|
||||
|
||||
export function isSenderAllowed(
|
||||
chatJid: string,
|
||||
sender: string,
|
||||
cfg: SenderAllowlistConfig,
|
||||
): boolean {
|
||||
const entry = getEntry(chatJid, cfg);
|
||||
if (entry.allow === '*') return true;
|
||||
return entry.allow.includes(sender);
|
||||
}
|
||||
|
||||
export function shouldDropMessage(
|
||||
chatJid: string,
|
||||
cfg: SenderAllowlistConfig,
|
||||
): boolean {
|
||||
return getEntry(chatJid, cfg).mode === 'drop';
|
||||
}
|
||||
|
||||
export function isTriggerAllowed(
|
||||
chatJid: string,
|
||||
sender: string,
|
||||
cfg: SenderAllowlistConfig,
|
||||
): boolean {
|
||||
const allowed = isSenderAllowed(chatJid, sender, cfg);
|
||||
if (!allowed && cfg.logDenied) {
|
||||
logger.debug(
|
||||
{ chatJid, sender },
|
||||
'sender-allowlist: trigger denied for sender',
|
||||
);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
Reference in New Issue
Block a user