fix: validate discord component numeric limits

This commit is contained in:
Peter Steinberger
2026-05-28 16:10:01 -04:00
parent 59205bd63c
commit 09c5b2dd37
3 changed files with 122 additions and 11 deletions

View File

@@ -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();
}

View File

@@ -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"],
};
}

View File

@@ -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({