diff --git a/apps/bot/src/channel-scope.test.ts b/apps/bot/src/channel-scope.test.ts new file mode 100644 index 0000000..507bcb7 --- /dev/null +++ b/apps/bot/src/channel-scope.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isChannelInGuild } from "./channel-scope.js"; + +const GUILD = "111111111111111111"; +const OTHER = "222222222222222222"; + +describe("isChannelInGuild", () => { + it("accepts a channel of the delivery's own guild", () => { + expect(isChannelInGuild({ id: "1", guildId: GUILD }, GUILD)).toBe(true); + }); + + it("rejects a channel on another server", () => { + expect(isChannelInGuild({ id: "1", guildId: OTHER }, GUILD)).toBe(false); + }); + + it("rejects channels without a guild, such as DMs", () => { + expect(isChannelInGuild({ id: "1" }, GUILD)).toBe(false); + expect(isChannelInGuild({ id: "1", guildId: null }, GUILD)).toBe(false); + }); + + it("rejects everything when the guild's Discord id is unknown", () => { + expect(isChannelInGuild({ id: "1", guildId: GUILD }, null)).toBe(false); + expect(isChannelInGuild({ id: "1", guildId: GUILD }, undefined)).toBe(false); + expect(isChannelInGuild({ id: "1", guildId: "" }, "")).toBe(false); + }); +}); diff --git a/apps/bot/src/channel-scope.ts b/apps/bot/src/channel-scope.ts new file mode 100644 index 0000000..2bfa226 --- /dev/null +++ b/apps/bot/src/channel-scope.ts @@ -0,0 +1,17 @@ +/** + * Whether a fetched Discord channel lives in the guild a delivery belongs to. + * + * Channel ids in a form's settings or a guild's bot config are only checked to + * be snowflakes when they are saved, and `client.channels.fetch` resolves any + * channel the bot can see, on any server it was invited to. Without this check + * a form manager could point the review channel at a server of their own and + * receive the applicants' answers there. DM channels have no guild and never + * qualify. + */ +export function isChannelInGuild( + channel: object, + discordGuildId: string | null | undefined, +): boolean { + if (!discordGuildId) return false; + return "guildId" in channel && channel.guildId === discordGuildId; +} diff --git a/apps/bot/src/notifications.ts b/apps/bot/src/notifications.ts index a5c4e19..f657b05 100644 --- a/apps/bot/src/notifications.ts +++ b/apps/bot/src/notifications.ts @@ -16,6 +16,7 @@ import { type Client, } from "discord.js"; +import { isChannelInGuild } from "./channel-scope.js"; import { config } from "./config.js"; import { isExhausted, isTerminalDmCode, nextAttemptAt } from "./delivery-policy.js"; import { describeError } from "./log.js"; @@ -107,7 +108,10 @@ async function deliverReview(client: Client, row: PendingRow): Promise // bot config. Look both up via the submission this notification is about. const sub = await prisma.submission.findUnique({ where: { id: payload.submissionId }, - select: { form: { select: { settings: true } }, guild: { select: { botConfig: true } } }, + select: { + form: { select: { settings: true } }, + guild: { select: { botConfig: true, discordGuildId: true } }, + }, }); const botCfg = parseBotConfig(sub?.guild.botConfig); const channelId = parseFormSettings(sub?.form.settings).reviewChannelId ?? botCfg.reviewChannelId; @@ -120,6 +124,10 @@ async function deliverReview(client: Client, row: PendingRow): Promise console.warn(`[bot] review channel ${channelId} not usable — dropping ${row.id}.`); return true; } + if (!isChannelInGuild(channel, sub?.guild.discordGuildId)) { + console.warn(`[bot] review channel ${channelId} is not in the form's guild — dropping ${row.id}.`); + return true; + } const url = dashboardSubmissionUrl(config.apiBaseUrl, row.guildId, payload.submissionId); const lines = [`**${s.applicant}:** ${payload.applicantName ?? s.anonymous}`]; @@ -168,7 +176,7 @@ async function deliverLog(client: Client, row: PendingRow): Promise { const guild = await prisma.guild.findUnique({ where: { id: row.guildId }, - select: { botConfig: true }, + select: { botConfig: true, discordGuildId: true }, }); const botCfg = parseBotConfig(guild?.botConfig); const channelId = botCfg.logChannelId; @@ -190,6 +198,10 @@ async function deliverLog(client: Client, row: PendingRow): Promise { console.warn(`[bot] log channel ${channelId} not usable — dropping ${row.id}.`); return true; } + if (!isChannelInGuild(channel, guild?.discordGuildId)) { + console.warn(`[bot] log channel ${channelId} is not in the guild — dropping ${row.id}.`); + return true; + } const components: ActionRowBuilder[] = []; if (dashboardUrl) { diff --git a/docs/features.md b/docs/features.md index 2d62791..21b1ed8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,6 +6,7 @@ What MSK Forms can do. Step-by-step instructions with screenshots are in the - **Powerful builder:** 20+ field types, multi-step pages, conditional logic, quiz scoring, calculated fields, and per-form scheduling with an optional public countdown. - **Real feedback loop:** every submission gets a live status page; applicants follow along without an account and can withdraw, export, or delete their data (GDPR). One active submission per person by default. - **Discord-native review:** new submissions post to a review channel with Accept / Reject buttons, status DMs, automatic role grants, and a full activity log, all in your server's language. + Review and log channels must be on your own server; the bot ignores channels on other servers. - **Make it yours:** accent color, logo, custom CSS with a live preview, form categories, a public form hub, and custom domains with automatic TLS. - **Team & workflow:** roles with per-form reviewer access, a Kanban board, bulk actions, custom statuses, automations, A/B tests, and CSV / XLSX / JSON / PDF exports. - **Integrations:** outgoing webhooks, Zapier / Make, and a REST API.