From a2f88cfe0f82971b8434feeea6dcf3130d4d55f9 Mon Sep 17 00:00:00 2001 From: orion Date: Wed, 25 Feb 2026 22:05:22 +0000 Subject: [PATCH] chore(security): add guardrails and PR merge summary docs --- CHANGELOG.md | 1 + discord-control-api/server.mjs | 53 ++++++++++++++++++++++++++++---- docs/DISCORD_CONTROL.md | 4 +++ docs/PR_SUMMARY.md | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 docs/PR_SUMMARY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b3321..16e9224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ - `channel-private-create` (create private channel for allowlist) - `channel-private-update` (update allowlist/overwrites for existing channel) - `member-list` (guild members list with pagination + optional field projection) + - guardrails: action mode validation, id-list limits, response-size limit diff --git a/discord-control-api/server.mjs b/discord-control-api/server.mjs index 8bd582b..27a21db 100644 --- a/discord-control-api/server.mjs +++ b/discord-control-api/server.mjs @@ -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, + }, }); } diff --git a/docs/DISCORD_CONTROL.md b/docs/DISCORD_CONTROL.md index aba5993..58daa3a 100644 --- a/docs/DISCORD_CONTROL.md +++ b/docs/DISCORD_CONTROL.md @@ -21,6 +21,10 @@ export AUTH_TOKEN='strong-token' # optional allowlist # export ALLOWED_GUILD_IDS='123,456' # export ALLOWED_CALLER_IDS='agent-main,agent-admin' +# optional limits +# export MAX_MEMBER_FIELDS=20 +# export MAX_MEMBER_RESPONSE_BYTES=500000 +# export MAX_PRIVATE_MUTATION_TARGETS=200 node server.mjs ``` diff --git a/docs/PR_SUMMARY.md b/docs/PR_SUMMARY.md new file mode 100644 index 0000000..380e0a8 --- /dev/null +++ b/docs/PR_SUMMARY.md @@ -0,0 +1,55 @@ +# PR Summary (WhisperGate + Discord Control) + +## Scope + +This PR delivers two tracks: + +1. WhisperGate deterministic no-reply gate for Discord sessions +2. Discord control extension API for private-channel/member-list gaps + +## Delivered Features + +### WhisperGate + +- Deterministic rule chain: + 1) non-discord => skip + 2) bypass sender => skip + 3) ending symbol matched => skip + 4) else => no-reply provider/model override +- `NO_REPLY` backend API (`/v1/chat/completions`, `/v1/responses`, `/v1/models`) +- Optional API bearer auth (`AUTH_TOKEN`) +- Prompt prepend on bypass/end-symbol paths: + - `你的这次发言必须以🔚作为结尾。` +- Rule validation script and fixtures + +### Discord Control API + +- `channel-private-create` +- `channel-private-update` (`merge`/`replace`) +- `member-list` with optional field projection +- Action gate + guild allowlist + caller allowlist + bearer auth +- Dry-run support for channel private actions + +## Runtime Mode + +- No-Docker-first +- Run directly with Node.js + +## Security Defaults (recommended) + +- Set `AUTH_TOKEN` +- Set `REQUIRE_AUTH_TOKEN=true` +- Use `ALLOWED_GUILD_IDS` +- Use `ALLOWED_CALLER_IDS` +- Keep Discord bot token in env only (`DISCORD_BOT_TOKEN`) + +## Known Limits + +- This repo cannot elevate bot privileges; Discord admin permissions still govern all actions. +- `member-list` depends on Discord API permission/intents availability. + +## Rollback + +- Disable plugin entry or remove plugin path from OpenClaw config +- Stop `discord-control-api` process +- Keep no-reply API stopped if not needed