fix(logging): redact Telegram bot tokens from timeout URLs (#99428)

* fix(net-policy): redact Telegram bot tokens from timeout URLs

* fix(net-policy): extend Telegram bot token redaction to all hostnames

* refactor(net-policy): centralize bot token URL redaction

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Lee
2026-07-03 05:05:38 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent f5c700bc4b
commit 4abdf0f3b5
4 changed files with 102 additions and 7 deletions
@@ -45,6 +45,19 @@ describe("redactSensitiveUrl", () => {
"https://example.com/mcp?safe=value",
);
});
it("redacts Telegram bot tokens from URL paths", () => {
expect(
redactSensitiveUrl(
"https://telegram.internal/bot123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd/getMe",
),
).toBe("https://telegram.internal/bot***/getMe");
expect(
redactSensitiveUrl(
"https://api.telegram.org/bot123456%3AABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd/getMe",
),
).toBe("https://api.telegram.org/bot***/getMe");
});
});
describe("redactSensitiveUrlLikeString", () => {
@@ -89,6 +102,14 @@ describe("redactSensitiveUrlLikeString", () => {
),
).toBe("wss://***:***@[bad-host/socket?token=***&keep=visible)");
});
it("redacts Telegram bot tokens from URL-like fallback strings", () => {
expect(
redactSensitiveUrlLikeString(
"timeout /bot123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd/sendMessage and keep /bot/settings",
),
).toBe("timeout /bot***/sendMessage and keep /bot/settings");
});
});
describe("isSensitiveUrlQueryParamName", () => {
@@ -41,6 +41,14 @@ const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
// category Lo, so \p{C}\p{Z} alone would let them splice sensitive key names.
const URL_QUERY_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
// Telegram Bot API credentials live in `/bot<token>/...` path segments rather
// than userinfo or query params. Keep this shape aligned with logging/redact.ts.
const TELEGRAM_BOT_TOKEN_PATH_RE = /\/bot\d{6,}(?::|%3[aA])[A-Za-z0-9_-]{20,}(?=\/|$)/giu;
function redactSensitiveUrlPath(value: string): string {
return value.replace(TELEGRAM_BOT_TOKEN_PATH_RE, "/bot***");
}
function normalizeUrlQueryParamName(name: string): string {
const stripped = name.replace(URL_QUERY_NAME_SEPARATOR_RE, "");
try {
@@ -82,6 +90,11 @@ export function redactSensitiveUrl(value: string): string {
try {
const parsed = new URL(value);
let mutated = false;
const redactedPath = redactSensitiveUrlPath(parsed.pathname);
if (redactedPath !== parsed.pathname) {
parsed.pathname = redactedPath;
mutated = true;
}
if (parsed.username || parsed.password) {
parsed.username = parsed.username ? "***" : "";
parsed.password = parsed.password ? "***" : "";
@@ -105,9 +118,10 @@ export function redactSensitiveUrlLikeString(value: string): string {
if (redactedUrl !== value) {
return redactedUrl;
}
return value
const redactedFallback = value
.replace(/\/\/([^@/?#\s]+)@/g, "//***:***@")
.replace(/([?&])([^=&]+)=([^&]*)/g, (match, prefix: string, key: string) =>
isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match,
);
return redactSensitiveUrlPath(redactedFallback);
}
+57
View File
@@ -13,6 +13,17 @@ vi.mock("../logging/subsystem.js", () => ({
import { buildTimeoutAbortSignal, fetchWithTimeout } from "./fetch-timeout.js";
import { MAX_SAFE_TIMEOUT_DELAY_MS } from "./timer-delay.js";
function captureTimeoutLogUrl(url: string): Promise<Record<string, unknown>> {
const { cleanup } = buildTimeoutAbortSignal({ timeoutMs: 25, operation: "unit-test", url });
return vi.advanceTimersByTimeAsync(25).then(() => {
const record = requireWarnRecord(0);
cleanup();
return record;
});
}
const SYNTHETIC_TELEGRAM_BOT_TOKEN = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd";
function requireWarnCall(callIndex: number): [string, Record<string, unknown>] {
const call = warn.mock.calls[callIndex];
if (!call) {
@@ -144,6 +155,52 @@ describe("buildTimeoutAbortSignal", () => {
cleanup();
});
it("redacts Telegram bot tokens from parseable timeout URLs", async () => {
const record = await captureTimeoutLogUrl(
`https://api.telegram.org/bot${SYNTHETIC_TELEGRAM_BOT_TOKEN}/sendMessage?chat_id=1`,
);
expect(record.url).toBe("https://api.telegram.org/bot***/sendMessage");
expect(JSON.stringify(record)).not.toContain(SYNTHETIC_TELEGRAM_BOT_TOKEN);
});
it("redacts Telegram bot tokens from custom self-hosted API root timeout URLs", async () => {
const record = await captureTimeoutLogUrl(
`https://telegram.internal/bot${SYNTHETIC_TELEGRAM_BOT_TOKEN}/getMe?chat_id=1`,
);
expect(record.url).toBe("https://telegram.internal/bot***/getMe");
expect(JSON.stringify(record)).not.toContain(SYNTHETIC_TELEGRAM_BOT_TOKEN);
});
it("redacts Telegram bot tokens from proxy API root timeout URLs", async () => {
const record = await captureTimeoutLogUrl(
`https://tg-proxy.example.com/bot${SYNTHETIC_TELEGRAM_BOT_TOKEN}/sendMessage?foo=bar`,
);
expect(record.url).toBe("https://tg-proxy.example.com/bot***/sendMessage");
expect(JSON.stringify(record)).not.toContain(SYNTHETIC_TELEGRAM_BOT_TOKEN);
});
it("redacts Telegram bot tokens from unparseable (fallback) timeout URL strings", async () => {
const record = await captureTimeoutLogUrl(
`/bot${SYNTHETIC_TELEGRAM_BOT_TOKEN}/sendMessage?chat_id=1`,
);
expect(record.url).toBe("/bot***/sendMessage");
expect(JSON.stringify(record)).not.toContain(SYNTHETIC_TELEGRAM_BOT_TOKEN);
});
it.each([
["https://example.com/bot/settings?safe=1", "https://example.com/bot/settings"],
["https://example.com/bots/chat?safe=1", "https://example.com/bots/chat"],
["https://example.com/robot/test?safe=1", "https://example.com/robot/test"],
["/bot123:status?safe=1", "/bot123:status"],
["/bot123456:short/status?safe=1", "/bot123456:short/status"],
])("keeps ordinary bot-like timeout path %s visible", async (input, expected) => {
expect((await captureTimeoutLogUrl(input)).url).toBe(expected);
});
it("tags fetch timeout aborts so callers can distinguish them from parent aborts", async () => {
const fetchFn = vi.fn<typeof fetch>(
async (_input, init) =>
+9 -6
View File
@@ -1,4 +1,5 @@
// Fetch timeout helpers wrap fetch calls with timeout and abort behavior.
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveSafeTimeoutDelayMs } from "./timer-delay.js";
@@ -39,15 +40,17 @@ function sanitizeTimeoutLogUrl(rawUrl: string | undefined): string | undefined {
parsed.password = "";
parsed.search = "";
parsed.hash = "";
const value = parsed.toString();
const value = redactSensitiveUrlLikeString(parsed.toString());
return value.length > LOG_URL_MAX_CHARS ? `${value.slice(0, LOG_URL_MAX_CHARS)}...` : value;
} catch {
const withoutQueryOrHash = trimmed.split(URL_SECRET_SUFFIX_PATTERN, 1)[0] ?? "";
const cleaned = withoutQueryOrHash
.replace(/[\r\n\u2028\u2029]+/g, " ")
.replace(/\p{Cc}+/gu, " ")
.replace(/\s+/g, " ")
.trim();
const cleaned = redactSensitiveUrlLikeString(
withoutQueryOrHash
.replace(/[\r\n\u2028\u2029]+/g, " ")
.replace(/\p{Cc}+/gu, " ")
.replace(/\s+/g, " ")
.trim(),
);
if (!cleaned) {
return undefined;
}