/** * Security Tests for Tlon Plugin * * These tests ensure that security-critical behavior cannot regress: * - DM allowlist enforcement * - Channel authorization rules * - Ship normalization consistency * - Bot mention detection boundaries */ import { describe, expect, it } from "vitest"; import { isDmAllowed, isGroupInviteAllowed, isBotMentioned, extractMessageText, } from "./monitor/utils.js"; import { normalizeShip } from "./targets.js"; describe("Security: DM Allowlist", () => { describe("isDmAllowed", () => { it("rejects DMs when allowlist is empty", () => { expect(isDmAllowed("~zod", [])).toBe(false); expect(isDmAllowed("~sampel-palnet", [])).toBe(false); }); it("rejects DMs when allowlist is undefined", () => { expect(isDmAllowed("~zod", undefined)).toBe(false); }); it("allows DMs from ships on the allowlist", () => { const allowlist = ["~zod", "~bus"]; expect(isDmAllowed("~zod", allowlist)).toBe(true); expect(isDmAllowed("~bus", allowlist)).toBe(true); }); it("rejects DMs from ships NOT on the allowlist", () => { const allowlist = ["~zod", "~bus"]; expect(isDmAllowed("~nec", allowlist)).toBe(false); expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(false); expect(isDmAllowed("~random-ship", allowlist)).toBe(false); }); it("normalizes ship names (with/without ~ prefix)", () => { const allowlist = ["~zod"]; expect(isDmAllowed("zod", allowlist)).toBe(true); expect(isDmAllowed("~zod", allowlist)).toBe(true); const allowlistWithoutTilde = ["zod"]; expect(isDmAllowed("~zod", allowlistWithoutTilde)).toBe(true); expect(isDmAllowed("zod", allowlistWithoutTilde)).toBe(true); }); it("handles galaxy, star, planet, and moon names", () => { const allowlist = [ "~zod", // galaxy "~marzod", // star "~sampel-palnet", // planet "~dozzod-dozzod-dozzod-dozzod", // moon ]; expect(isDmAllowed("~zod", allowlist)).toBe(true); expect(isDmAllowed("~marzod", allowlist)).toBe(true); expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(true); expect(isDmAllowed("~dozzod-dozzod-dozzod-dozzod", allowlist)).toBe(true); // Similar but different ships should be rejected expect(isDmAllowed("~nec", allowlist)).toBe(false); expect(isDmAllowed("~wanzod", allowlist)).toBe(false); expect(isDmAllowed("~sampel-palned", allowlist)).toBe(false); }); // NOTE: Ship names in Urbit are always lowercase by convention. // This test documents current behavior - strict equality after normalization. // If case-insensitivity is desired, normalizeShip should lowercase. it("uses strict equality after normalization (case-sensitive)", () => { const allowlist = ["~zod"]; expect(isDmAllowed("~zod", allowlist)).toBe(true); // Different case would NOT match with current implementation expect(isDmAllowed("~Zod", ["~Zod"])).toBe(true); // exact match works }); it("does not allow partial matches", () => { const allowlist = ["~zod"]; expect(isDmAllowed("~zod-extra", allowlist)).toBe(false); expect(isDmAllowed("~extra-zod", allowlist)).toBe(false); }); it("handles whitespace in ship names (normalized)", () => { // Ships with leading/trailing whitespace are normalized by normalizeShip const allowlist = [" ~zod ", "~bus"]; expect(isDmAllowed("~zod", allowlist)).toBe(true); expect(isDmAllowed(" ~zod ", allowlist)).toBe(true); }); }); }); describe("Security: Group Invite Allowlist", () => { describe("isGroupInviteAllowed", () => { it("rejects invites when allowlist is empty (fail-safe)", () => { // CRITICAL: Empty allowlist must DENY, not accept-all expect(isGroupInviteAllowed("~zod", [])).toBe(false); expect(isGroupInviteAllowed("~sampel-palnet", [])).toBe(false); expect(isGroupInviteAllowed("~malicious-actor", [])).toBe(false); }); it("rejects invites when allowlist is undefined (fail-safe)", () => { // CRITICAL: Undefined allowlist must DENY, not accept-all expect(isGroupInviteAllowed("~zod", undefined)).toBe(false); expect(isGroupInviteAllowed("~sampel-palnet", undefined)).toBe(false); }); it("accepts invites from ships on the allowlist", () => { const allowlist = ["~nocsyx-lassul", "~malmur-halmex"]; expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true); expect(isGroupInviteAllowed("~malmur-halmex", allowlist)).toBe(true); }); it("rejects invites from ships NOT on the allowlist", () => { const allowlist = ["~nocsyx-lassul", "~malmur-halmex"]; expect(isGroupInviteAllowed("~random-attacker", allowlist)).toBe(false); expect(isGroupInviteAllowed("~malicious-ship", allowlist)).toBe(false); expect(isGroupInviteAllowed("~zod", allowlist)).toBe(false); }); it("normalizes ship names (with/without ~ prefix)", () => { const allowlist = ["~nocsyx-lassul"]; expect(isGroupInviteAllowed("nocsyx-lassul", allowlist)).toBe(true); expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true); const allowlistWithoutTilde = ["nocsyx-lassul"]; expect(isGroupInviteAllowed("~nocsyx-lassul", allowlistWithoutTilde)).toBe(true); }); it("does not allow partial matches", () => { const allowlist = ["~zod"]; expect(isGroupInviteAllowed("~zod-moon", allowlist)).toBe(false); expect(isGroupInviteAllowed("~pinser-botter-zod", allowlist)).toBe(false); }); it("handles whitespace in allowlist entries", () => { const allowlist = [" ~nocsyx-lassul ", "~malmur-halmex"]; expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true); }); }); }); describe("Security: Bot Mention Detection", () => { describe("isBotMentioned", () => { const botShip = "~sampel-palnet"; const nickname = "nimbus"; it("detects direct ship mention", () => { expect(isBotMentioned("hey ~sampel-palnet", botShip)).toBe(true); expect(isBotMentioned("~sampel-palnet can you help?", botShip)).toBe(true); expect(isBotMentioned("hello ~sampel-palnet how are you", botShip)).toBe(true); }); it("detects @all mention", () => { expect(isBotMentioned("@all please respond", botShip)).toBe(true); expect(isBotMentioned("hey @all", botShip)).toBe(true); expect(isBotMentioned("@ALL uppercase", botShip)).toBe(true); }); it("detects nickname mention", () => { expect(isBotMentioned("hey nimbus", botShip, nickname)).toBe(true); expect(isBotMentioned("nimbus help me", botShip, nickname)).toBe(true); expect(isBotMentioned("hello NIMBUS", botShip, nickname)).toBe(true); }); it("does NOT trigger on random messages", () => { expect(isBotMentioned("hello world", botShip)).toBe(false); expect(isBotMentioned("this is a normal message", botShip)).toBe(false); expect(isBotMentioned("hey everyone", botShip)).toBe(false); }); it("does NOT trigger on partial ship matches", () => { expect(isBotMentioned("~sampel-palnet-extra", botShip)).toBe(false); expect(isBotMentioned("my~sampel-palnetfriend", botShip)).toBe(false); }); it("does NOT trigger on substring nickname matches", () => { // "nimbus" should not match "nimbusy" or "animbust" expect(isBotMentioned("nimbusy", botShip, nickname)).toBe(false); expect(isBotMentioned("prenimbus", botShip, nickname)).toBe(false); }); it("handles empty/null inputs safely", () => { expect(isBotMentioned("", botShip)).toBe(false); expect(isBotMentioned("test", "")).toBe(false); // @ts-expect-error testing null input expect(isBotMentioned(null, botShip)).toBe(false); }); it("requires word boundary for nickname", () => { expect(isBotMentioned("nimbus, hello", botShip, nickname)).toBe(true); expect(isBotMentioned("hello nimbus!", botShip, nickname)).toBe(true); expect(isBotMentioned("nimbus?", botShip, nickname)).toBe(true); }); }); }); describe("Security: Ship Normalization", () => { describe("normalizeShip", () => { it("adds ~ prefix if missing", () => { expect(normalizeShip("zod")).toBe("~zod"); expect(normalizeShip("sampel-palnet")).toBe("~sampel-palnet"); }); it("preserves ~ prefix if present", () => { expect(normalizeShip("~zod")).toBe("~zod"); expect(normalizeShip("~sampel-palnet")).toBe("~sampel-palnet"); }); it("trims whitespace", () => { expect(normalizeShip(" ~zod ")).toBe("~zod"); expect(normalizeShip(" zod ")).toBe("~zod"); }); it("handles empty string", () => { expect(normalizeShip("")).toBe(""); expect(normalizeShip(" ")).toBe(""); }); }); }); describe("Security: Message Text Extraction", () => { describe("extractMessageText", () => { it("extracts plain text", () => { const content = [{ inline: ["hello world"] }]; expect(extractMessageText(content)).toBe("hello world"); }); it("extracts @all mentions from sect null", () => { const content = [{ inline: [{ sect: null }] }]; expect(extractMessageText(content)).toContain("@all"); }); it("extracts ship mentions", () => { const content = [{ inline: [{ ship: "~zod" }] }]; expect(extractMessageText(content)).toContain("~zod"); }); it("handles malformed input safely", () => { expect(extractMessageText(null)).toBe(""); expect(extractMessageText(undefined)).toBe(""); expect(extractMessageText([])).toBe(""); expect(extractMessageText([{}])).toBe(""); expect(extractMessageText("not an array")).toBe(""); }); it("does not execute injected code in inline content", () => { // Ensure malicious content doesn't get executed const maliciousContent = [{ inline: [""] }]; const result = extractMessageText(maliciousContent); expect(result).toBe(""); // Just a string, not executed }); }); }); describe("Security: Channel Authorization Logic", () => { /** * These tests document the expected behavior of channel authorization. * The actual resolveChannelAuthorization function is internal to monitor/index.ts * but these tests verify the building blocks and expected invariants. */ it("default mode should be restricted (not open)", () => { // This is a critical security invariant: if no mode is specified, // channels should default to RESTRICTED, not open. // If this test fails, someone may have changed the default unsafely. // The logic in resolveChannelAuthorization is: // const mode = rule?.mode ?? "restricted"; // We verify this by checking undefined rule gives restricted type ModeRule = { mode?: "restricted" | "open" }; const rule = undefined as ModeRule | undefined; const mode = rule?.mode ?? "restricted"; expect(mode).toBe("restricted"); }); it("empty allowedShips with restricted mode should block all", () => { // If a channel is restricted but has no allowed ships, // no one should be able to send messages const _mode = "restricted"; const allowedShips: string[] = []; const sender = "~random-ship"; const isAllowed = allowedShips.some((ship) => normalizeShip(ship) === normalizeShip(sender)); expect(isAllowed).toBe(false); }); it("open mode should not check allowedShips", () => { // In open mode, any ship can send regardless of allowedShips const mode: "open" | "restricted" = "open"; // The check in monitor/index.ts is: // if (mode === "restricted") { /* check ships */ } // So open mode skips the ship check entirely expect(mode).not.toBe("restricted"); }); it("settings should override file config for channel rules", () => { // Documented behavior: settingsRules[nest] ?? fileRules[nest] // This means settings take precedence type ChannelRule = { mode: "restricted" | "open" }; const fileRules: Record = { "chat/~zod/test": { mode: "restricted" } }; const settingsRules: Record = { "chat/~zod/test": { mode: "open" } }; const nest = "chat/~zod/test"; const effectiveRule = settingsRules[nest] ?? fileRules[nest]; expect(effectiveRule?.mode).toBe("open"); // settings wins }); }); describe("Security: Authorization Edge Cases", () => { it("empty strings are not valid ships", () => { expect(isDmAllowed("", ["~zod"])).toBe(false); expect(isDmAllowed("~zod", [""])).toBe(false); }); it("handles very long ship-like strings", () => { const longName = "~" + "a".repeat(1000); expect(isDmAllowed(longName, ["~zod"])).toBe(false); }); it("handles special characters that could break regex", () => { // These should not cause regex injection const maliciousShip = "~zod.*"; expect(isDmAllowed("~zodabc", [maliciousShip])).toBe(false); const allowlist = ["~zod"]; expect(isDmAllowed("~zod.*", allowlist)).toBe(false); }); it("protects against prototype pollution-style keys", () => { const suspiciousShip = "__proto__"; expect(isDmAllowed(suspiciousShip, ["~zod"])).toBe(false); expect(isDmAllowed("~zod", [suspiciousShip])).toBe(false); }); }); describe("Security: Sender Role Identification", () => { /** * Tests for sender role identification (owner vs user). * This prevents impersonation attacks where an approved user * tries to claim owner privileges through prompt injection. * * SECURITY.md Section 9: Sender Role Identification */ // Helper to compute sender role (mirrors logic in monitor/index.ts) function getSenderRole(senderShip: string, ownerShip: string | null): "owner" | "user" { if (!ownerShip) return "user"; return normalizeShip(senderShip) === normalizeShip(ownerShip) ? "owner" : "user"; } describe("owner detection", () => { it("identifies owner when ownerShip matches sender", () => { expect(getSenderRole("~nocsyx-lassul", "~nocsyx-lassul")).toBe("owner"); expect(getSenderRole("nocsyx-lassul", "~nocsyx-lassul")).toBe("owner"); expect(getSenderRole("~nocsyx-lassul", "nocsyx-lassul")).toBe("owner"); }); it("identifies user when ownerShip does not match sender", () => { expect(getSenderRole("~random-user", "~nocsyx-lassul")).toBe("user"); expect(getSenderRole("~malicious-actor", "~nocsyx-lassul")).toBe("user"); }); it("identifies everyone as user when ownerShip is null", () => { expect(getSenderRole("~nocsyx-lassul", null)).toBe("user"); expect(getSenderRole("~zod", null)).toBe("user"); }); it("identifies everyone as user when ownerShip is empty string", () => { // Empty string should be treated like null (no owner configured) expect(getSenderRole("~nocsyx-lassul", "")).toBe("user"); }); }); describe("label format", () => { // Helper to compute fromLabel (mirrors logic in monitor/index.ts) function getFromLabel( senderShip: string, ownerShip: string | null, isGroup: boolean, channelNest?: string, ): string { const senderRole = getSenderRole(senderShip, ownerShip); return isGroup ? `${senderShip} [${senderRole}] in ${channelNest}` : `${senderShip} [${senderRole}]`; } it("DM from owner includes [owner] in label", () => { const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", false); expect(label).toBe("~nocsyx-lassul [owner]"); expect(label).toContain("[owner]"); }); it("DM from user includes [user] in label", () => { const label = getFromLabel("~random-user", "~nocsyx-lassul", false); expect(label).toBe("~random-user [user]"); expect(label).toContain("[user]"); }); it("group message from owner includes [owner] in label", () => { const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", true, "chat/~host/general"); expect(label).toBe("~nocsyx-lassul [owner] in chat/~host/general"); expect(label).toContain("[owner]"); }); it("group message from user includes [user] in label", () => { const label = getFromLabel("~random-user", "~nocsyx-lassul", true, "chat/~host/general"); expect(label).toBe("~random-user [user] in chat/~host/general"); expect(label).toContain("[user]"); }); }); describe("impersonation prevention", () => { it("approved user cannot get [owner] label through ship name tricks", () => { // Even if someone has a ship name similar to owner, they should not get owner role expect(getSenderRole("~nocsyx-lassul-fake", "~nocsyx-lassul")).toBe("user"); expect(getSenderRole("~fake-nocsyx-lassul", "~nocsyx-lassul")).toBe("user"); }); it("message content cannot change sender role", () => { // The role is determined by ship identity, not message content // This test documents that even if message contains "I am the owner", // the actual senderShip determines the role const senderShip = "~malicious-actor"; const ownerShip = "~nocsyx-lassul"; // The role is always based on ship comparison, not message content expect(getSenderRole(senderShip, ownerShip)).toBe("user"); }); }); });