From 09c5b2dd3772e6fb7ebd8faf9a95abb8ce77b92a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 16:10:01 -0400 Subject: [PATCH] fix: validate discord component numeric limits --- extensions/discord/src/components.modal.ts | 2 - extensions/discord/src/components.parse.ts | 42 +++++++--- extensions/discord/src/components.test.ts | 89 ++++++++++++++++++++++ 3 files changed, 122 insertions(+), 11 deletions(-) diff --git a/extensions/discord/src/components.modal.ts b/extensions/discord/src/components.modal.ts index ed7371ada06..c360fba7217 100644 --- a/extensions/discord/src/components.modal.ts +++ b/extensions/discord/src/components.modal.ts @@ -82,8 +82,6 @@ function createModalFieldComponent( customId = field.id; override options = options; override required = field.required; - override minValues = field.minValues; - override maxValues = field.maxValues; } return new DynamicRadioGroup(); } diff --git a/extensions/discord/src/components.parse.ts b/extensions/discord/src/components.parse.ts index e48dc2a1907..149f6aa3b07 100644 --- a/extensions/discord/src/components.parse.ts +++ b/extensions/discord/src/components.parse.ts @@ -62,10 +62,23 @@ function readOptionalStringArray(value: unknown, label: string): string[] | unde return value.map((entry, index) => readString(entry, `${label}[${index}]`)); } -function readOptionalNumber(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) { +function readOptionalInteger( + value: unknown, + label: string, + bounds?: { min?: number; max?: number }, +): number | undefined { + if (value == null) { return undefined; } + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new Error(`${label} must be an integer`); + } + if (bounds?.min !== undefined && value < bounds.min) { + throw new Error(`${label} must be at least ${bounds.min}`); + } + if (bounds?.max !== undefined && value > bounds.max) { + throw new Error(`${label} must be at most ${bounds.max}`); + } return value; } @@ -197,8 +210,8 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe type, callbackData: readOptionalString(obj.callbackData), placeholder: readOptionalString(obj.placeholder), - minValues: readOptionalNumber(obj.minValues), - maxValues: readOptionalNumber(obj.maxValues), + minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }), + maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }), options: parseSelectOptions(obj.options, `${label}.options`), allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`), }; @@ -224,18 +237,29 @@ function parseModalField(raw: unknown, label: string, index: number): DiscordMod if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) { throw new Error(`${label}.options is required for ${type} fields`); } + if (type === "radio" && (obj.minValues != null || obj.maxValues != null)) { + throw new Error(`${label}.minValues/maxValues are not supported for radio fields`); + } + const required = typeof obj.required === "boolean" ? obj.required : undefined; + const maxValues = type === "checkbox" ? 10 : 25; return { type, name: normalizeModalFieldName(readOptionalString(obj.name), index), label: readString(obj.label, `${label}.label`), description: readOptionalString(obj.description), placeholder: readOptionalString(obj.placeholder), - required: typeof obj.required === "boolean" ? obj.required : undefined, + required, options, - minValues: readOptionalNumber(obj.minValues), - maxValues: readOptionalNumber(obj.maxValues), - minLength: readOptionalNumber(obj.minLength), - maxLength: readOptionalNumber(obj.maxLength), + minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { + min: required === false ? 0 : 1, + max: maxValues, + }), + maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { + min: 1, + max: maxValues, + }), + minLength: readOptionalInteger(obj.minLength, `${label}.minLength`, { min: 0, max: 4000 }), + maxLength: readOptionalInteger(obj.maxLength, `${label}.maxLength`, { min: 1, max: 4000 }), style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"], }; } diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 2b377f4aa6e..d0cde305a54 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -106,6 +106,95 @@ describe("discord components", () => { ).toThrow("options"); }); + it("rejects malformed component count and length limits", () => { + expect(() => + readDiscordComponentSpec({ + blocks: [ + { + type: "actions", + select: { + type: "string", + minValues: -1, + options: [{ label: "One", value: "one" }], + }, + }, + ], + }), + ).toThrow("components.blocks[0].select.minValues"); + + expect(() => + readDiscordComponentSpec({ + modal: { + title: "Details", + fields: [{ type: "text", label: "Name", maxLength: 0 }], + }, + }), + ).toThrow("components.modal.fields[0].maxLength"); + + expect(() => + readDiscordComponentSpec({ + modal: { + title: "Details", + fields: [ + { + type: "select", + label: "Priority", + minValues: 0, + options: [{ label: "High", value: "high" }], + }, + ], + }, + }), + ).toThrow("components.modal.fields[0].minValues"); + + expect(() => + readDiscordComponentSpec({ + modal: { + title: "Details", + fields: [ + { + type: "checkbox", + label: "Choices", + maxValues: 25, + options: [{ label: "One", value: "one" }], + }, + ], + }, + }), + ).toThrow("components.modal.fields[0].maxValues"); + + expect(() => + readDiscordComponentSpec({ + blocks: [ + { + type: "actions", + select: { + type: "string", + maxValues: 0, + options: [{ label: "One", value: "one" }], + }, + }, + ], + }), + ).toThrow("components.blocks[0].select.maxValues"); + + expect(() => + readDiscordComponentSpec({ + modal: { + title: "Details", + fields: [ + { + type: "radio", + label: "Choice", + minValues: 1, + options: [{ label: "One", value: "one" }], + }, + ], + }, + }), + ).toThrow("components.modal.fields[0].minValues/maxValues"); + }); + it("requires attachment references for file blocks", () => { expect(() => readDiscordComponentSpec({