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:
parent
492daaf709
commit
003cb44007
3 changed files with 45 additions and 5 deletions
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue