From 5c6fa68f9daeb1ed9196943f3cf39b17b8f5c014 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 5 Nov 2026 16:15:48 +0100 Subject: [PATCH] fix(bot): only post review and log entries to channels of the form's guild Channel ids in form settings and the guild bot config are only validated as snowflakes, and client.channels.fetch resolves any channel the bot can see on any server. A form manager could set the review channel to a channel on a server of their own and receive the applicants' names and answer previews there. The bot now drops a review or log delivery when the fetched channel does not belong to the guild the notification is for. The Accept/Reject buttons were already scoped to the guild. --- apps/bot/src/channel-scope.test.ts | 27 +++++++++++++++++++++++++++ apps/bot/src/channel-scope.ts | 17 +++++++++++++++++ apps/bot/src/notifications.ts | 16 ++++++++++++++-- docs/features.md | 1 + 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 apps/bot/src/channel-scope.test.ts create mode 100644 apps/bot/src/channel-scope.ts 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.