fix(security): sanitize error messages to prevent token leakage

Error messages from adapter auth/send failures may contain token
fragments. Added sanitizeError() that strips Slack token patterns
(xoxb-, xoxp-, xoxa-) and long opaque secrets (20+ alphanumeric
chars). Also truncates verbose Discord API error responses to 200
chars. Applied to all error paths in manager.ts and discord-adapter.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Facu_Viñas 2026-03-11 18:01:42 -03:00
parent 492daaf709
commit 003cb44007
3 changed files with 45 additions and 5 deletions

View file

@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts";
import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts";
import { sanitizeError } from "../../remote-questions/manager.ts";
test("parseSlackReply handles single-number single-question answers", () => {
const result = parseSlackReply("2", [{
@ -131,3 +132,24 @@ test("isValidChannelId rejects invalid Discord channel IDs", () => {
assert.equal(isValidChannelId("discord", "11234567890123456789"), true);
});
test("sanitizeError strips Slack token patterns from error messages", () => {
assert.equal(
sanitizeError("Auth failed: xoxb-1234-5678-abcdef"),
"Auth failed: [REDACTED]",
);
assert.equal(
sanitizeError("Bad token xoxp-abc-def-ghi in request"),
"Bad token [REDACTED] in request",
);
});
test("sanitizeError strips long opaque secrets", () => {
const fakeDiscordToken = "MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.G1x2y3.abcdefghijklmnop";
assert.ok(!sanitizeError(`Token: ${fakeDiscordToken}`).includes(fakeDiscordToken));
});
test("sanitizeError preserves short safe messages", () => {
assert.equal(sanitizeError("HTTP 401: Unauthorized"), "HTTP 401: Unauthorized");
assert.equal(sanitizeError("Connection refused"), "Connection refused");
});

View file

@ -114,7 +114,9 @@ export class DiscordAdapter implements ChannelAdapter {
if (response.status === 204) return {};
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Discord API HTTP ${response.status}: ${text}`);
// Limit error body length to avoid leaking verbose Discord error responses
const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text;
throw new Error(`Discord API HTTP ${response.status}: ${safeText}`);
}
return response.json();
}

View file

@ -36,7 +36,7 @@ export async function tryRemoteQuestions(
try {
await adapter.validate();
} catch (err) {
markPromptStatus(prompt.id, "failed", String((err as Error).message));
markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message)));
return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel);
}
@ -45,7 +45,7 @@ export async function tryRemoteQuestions(
dispatch = await adapter.sendPrompt(prompt);
markPromptDispatched(prompt.id, dispatch.ref);
} catch (err) {
markPromptStatus(prompt.id, "failed", String((err as Error).message));
markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message)));
return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel);
}
@ -128,7 +128,7 @@ async function pollUntilDone(
updatePromptRecord(prompt.id, { lastPollAt: Date.now() });
if (answer) return answer;
} catch (err) {
markPromptStatus(prompt.id, "failed", String((err as Error).message));
markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message)));
return null;
}
@ -163,9 +163,25 @@ function formatForTool(answer: RemoteAnswer): Record<string, { answers: string[]
return out;
}
// Strip token-like strings from error messages before surfacing
const TOKEN_PATTERNS = [
/xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens
/xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens
/xoxa-[A-Za-z0-9\-]+/g, // Slack app tokens
/[A-Za-z0-9_\-.]{20,}/g, // Long opaque secrets (Discord tokens, etc.)
];
export function sanitizeError(msg: string): string {
let sanitized = msg;
for (const pattern of TOKEN_PATTERNS) {
sanitized = sanitized.replace(pattern, "[REDACTED]");
}
return sanitized;
}
function errorResult(message: string, channel: string): ToolResult {
return {
content: [{ type: "text", text: message }],
content: [{ type: "text", text: sanitizeError(message) }],
details: { remote: true, channel, error: true, status: "failed" },
};
}