chore(security): add guardrails and PR merge summary docs
This commit is contained in:
@@ -30,6 +30,10 @@ const BIT_VIEW_CHANNEL = 1024n;
|
||||
const BIT_SEND_MESSAGES = 2048n;
|
||||
const BIT_READ_MESSAGE_HISTORY = 65536n;
|
||||
|
||||
const MAX_MEMBER_FIELDS = Math.max(1, Number(process.env.MAX_MEMBER_FIELDS || 20));
|
||||
const MAX_MEMBER_RESPONSE_BYTES = Math.max(2048, Number(process.env.MAX_MEMBER_RESPONSE_BYTES || 500000));
|
||||
const MAX_PRIVATE_MUTATION_TARGETS = Math.max(1, Number(process.env.MAX_PRIVATE_MUTATION_TARGETS || 200));
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(payload));
|
||||
@@ -148,6 +152,18 @@ function parseFieldList(input) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeIdList(value, label) {
|
||||
const arr = Array.isArray(value) ? value.map(String).map((v) => v.trim()).filter(Boolean) : [];
|
||||
if (arr.length > MAX_PRIVATE_MUTATION_TARGETS) {
|
||||
throw fail(400, "bad_request", `${label} exceeds MAX_PRIVATE_MUTATION_TARGETS`, {
|
||||
label,
|
||||
limit: MAX_PRIVATE_MUTATION_TARGETS,
|
||||
size: arr.length,
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function pick(obj, keys) {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
const out = {};
|
||||
@@ -186,8 +202,8 @@ async function actionChannelPrivateCreate(body) {
|
||||
nsfw: typeof body.nsfw === "boolean" ? body.nsfw : undefined,
|
||||
permission_overwrites: buildPrivateOverwrites({
|
||||
guildId,
|
||||
allowedUserIds: Array.isArray(body.allowedUserIds) ? body.allowedUserIds.map(String) : [],
|
||||
allowedRoleIds: Array.isArray(body.allowedRoleIds) ? body.allowedRoleIds.map(String) : [],
|
||||
allowedUserIds: normalizeIdList(body.allowedUserIds, "allowedUserIds"),
|
||||
allowedRoleIds: normalizeIdList(body.allowedRoleIds, "allowedRoleIds"),
|
||||
allowMask: body.allowMask,
|
||||
denyEveryoneMask: body.denyEveryoneMask,
|
||||
}),
|
||||
@@ -215,10 +231,13 @@ async function actionChannelPrivateUpdate(body) {
|
||||
const allowMask = toStringMask(body.allowMask, BIT_VIEW_CHANNEL | BIT_SEND_MESSAGES | BIT_READ_MESSAGE_HISTORY);
|
||||
const denyMask = toStringMask(body.denyMask, 0n);
|
||||
const mode = String(body.mode || "merge").trim();
|
||||
if (mode !== "merge" && mode !== "replace") {
|
||||
throw fail(400, "bad_request", "mode must be merge or replace", { mode });
|
||||
}
|
||||
|
||||
const addUserIds = Array.isArray(body.addUserIds) ? body.addUserIds.map(String) : [];
|
||||
const addRoleIds = Array.isArray(body.addRoleIds) ? body.addRoleIds.map(String) : [];
|
||||
const removeTargetIds = Array.isArray(body.removeTargetIds) ? body.removeTargetIds.map(String) : [];
|
||||
const addUserIds = normalizeIdList(body.addUserIds, "addUserIds");
|
||||
const addRoleIds = normalizeIdList(body.addRoleIds, "addRoleIds");
|
||||
const removeTargetIds = normalizeIdList(body.removeTargetIds, "removeTargetIds");
|
||||
|
||||
const existing = await discordRequest(`/channels/${channelId}`);
|
||||
const existingOverwrites = Array.isArray(existing.permission_overwrites) ? existing.permission_overwrites : [];
|
||||
@@ -275,6 +294,12 @@ async function actionMemberList(body) {
|
||||
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
|
||||
const after = body.after ? String(body.after) : undefined;
|
||||
const fields = parseFieldList(body.fields);
|
||||
if (fields.length > MAX_MEMBER_FIELDS) {
|
||||
throw fail(400, "bad_request", "fields exceeds MAX_MEMBER_FIELDS", {
|
||||
limit: MAX_MEMBER_FIELDS,
|
||||
size: fields.length,
|
||||
});
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", String(limit));
|
||||
@@ -283,7 +308,7 @@ async function actionMemberList(body) {
|
||||
const members = await discordRequest(`/guilds/${guildId}/members?${qs.toString()}`);
|
||||
const projected = Array.isArray(members) ? members.map((m) => projectMember(m, fields)) : members;
|
||||
|
||||
return {
|
||||
const response = {
|
||||
ok: true,
|
||||
action: "member-list",
|
||||
guildId,
|
||||
@@ -291,6 +316,17 @@ async function actionMemberList(body) {
|
||||
fields: fields.length ? fields : undefined,
|
||||
members: projected,
|
||||
};
|
||||
|
||||
const bytes = Buffer.byteLength(JSON.stringify(response), "utf8");
|
||||
if (bytes > MAX_MEMBER_RESPONSE_BYTES) {
|
||||
throw fail(413, "response_too_large", "member-list response exceeds MAX_MEMBER_RESPONSE_BYTES", {
|
||||
bytes,
|
||||
limit: MAX_MEMBER_RESPONSE_BYTES,
|
||||
hint: "reduce limit or set fields projection",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handleAction(body) {
|
||||
@@ -313,6 +349,11 @@ const server = http.createServer((req, res) => {
|
||||
authRequired: !!authToken || requireAuthToken,
|
||||
actionGates: enabledActions,
|
||||
guildAllowlistEnabled: allowedGuildIds.size > 0,
|
||||
limits: {
|
||||
maxMemberFields: MAX_MEMBER_FIELDS,
|
||||
maxMemberResponseBytes: MAX_MEMBER_RESPONSE_BYTES,
|
||||
maxPrivateMutationTargets: MAX_PRIVATE_MUTATION_TARGETS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user