mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 19:32:27 +00:00
fix: redact telegram media fetch errors
This commit is contained in:
@@ -25,13 +25,21 @@ function makeStallingFetch(firstChunk: Uint8Array) {
|
||||
});
|
||||
}
|
||||
|
||||
function makeLookupFn() {
|
||||
return vi.fn(async () => [{ address: "149.154.167.220", family: 4 }]) as unknown as NonNullable<
|
||||
Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]
|
||||
>;
|
||||
}
|
||||
|
||||
describe("fetchRemoteMedia", () => {
|
||||
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const telegramToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
|
||||
const redactedTelegramToken = `${telegramToken.slice(0, 6)}…${telegramToken.slice(-4)}`;
|
||||
const telegramFileUrl = `https://api.telegram.org/file/bot${telegramToken}/photos/1.jpg`;
|
||||
|
||||
it("rejects when content-length exceeds maxBytes", async () => {
|
||||
const lookupFn = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const fetchImpl = async () =>
|
||||
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
|
||||
status: 200,
|
||||
@@ -51,7 +59,7 @@ describe("fetchRemoteMedia", () => {
|
||||
it("rejects when streamed payload exceeds maxBytes", async () => {
|
||||
const lookupFn = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const fetchImpl = async () =>
|
||||
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), {
|
||||
status: 200,
|
||||
@@ -70,7 +78,7 @@ describe("fetchRemoteMedia", () => {
|
||||
it("aborts stalled body reads when idle timeout expires", async () => {
|
||||
const lookupFn = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
|
||||
const fetchImpl = makeStallingFetch(new Uint8Array([1, 2]));
|
||||
|
||||
await expect(
|
||||
@@ -87,6 +95,48 @@ describe("fetchRemoteMedia", () => {
|
||||
});
|
||||
}, 5_000);
|
||||
|
||||
it("redacts Telegram bot tokens from fetch failure messages", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error(`dial failed for ${telegramFileUrl}`);
|
||||
});
|
||||
|
||||
const error = await fetchRemoteMedia({
|
||||
url: telegramFileUrl,
|
||||
fetchImpl,
|
||||
lookupFn: makeLookupFn(),
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: {
|
||||
allowedHostnames: ["api.telegram.org"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}).catch((err: unknown) => err as Error);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const errorText = error instanceof Error ? String(error) : "";
|
||||
expect(errorText).not.toContain(telegramToken);
|
||||
expect(errorText).toContain(`bot${redactedTelegramToken}`);
|
||||
});
|
||||
|
||||
it("redacts Telegram bot tokens from HTTP error messages", async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response("unauthorized", { status: 401 }));
|
||||
|
||||
const error = await fetchRemoteMedia({
|
||||
url: telegramFileUrl,
|
||||
fetchImpl,
|
||||
lookupFn: makeLookupFn(),
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: {
|
||||
allowedHostnames: ["api.telegram.org"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}).catch((err: unknown) => err as Error);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const errorText = error instanceof Error ? String(error) : "";
|
||||
expect(errorText).not.toContain(telegramToken);
|
||||
expect(errorText).toContain(`bot${redactedTelegramToken}`);
|
||||
});
|
||||
|
||||
it("blocks private IP literals before fetching", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { fetchWithSsrFGuard, withStrictGuardedFetchMode } from "../infra/net/fetch-guard.js";
|
||||
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
import { detectMime, extensionForMime } from "./mime.js";
|
||||
import { readResponseWithLimit } from "./read-response-with-limit.js";
|
||||
|
||||
@@ -84,6 +86,10 @@ async function readErrorBodySnippet(res: Response, maxChars = 200): Promise<stri
|
||||
}
|
||||
}
|
||||
|
||||
function redactMediaUrl(url: string): string {
|
||||
return redactSensitiveText(url);
|
||||
}
|
||||
|
||||
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
|
||||
const {
|
||||
url,
|
||||
@@ -99,6 +105,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
fallbackDispatcherPolicy,
|
||||
shouldRetryFetchError,
|
||||
} = options;
|
||||
const sourceUrl = redactMediaUrl(url);
|
||||
|
||||
let res: Response;
|
||||
let finalUrl = url;
|
||||
@@ -129,7 +136,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
result = await runGuardedFetch(fallbackDispatcherPolicy);
|
||||
} catch (fallbackErr) {
|
||||
const combined = new Error(
|
||||
`Primary fetch failed and fallback fetch also failed for ${url}`,
|
||||
`Primary fetch failed and fallback fetch also failed for ${sourceUrl}`,
|
||||
{ cause: fallbackErr },
|
||||
);
|
||||
(combined as Error & { primaryError?: unknown }).primaryError = err;
|
||||
@@ -143,15 +150,19 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
finalUrl = result.finalUrl;
|
||||
release = result.release;
|
||||
} catch (err) {
|
||||
throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}: ${String(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
throw new MediaFetchError(
|
||||
"fetch_failed",
|
||||
`Failed to fetch media from ${sourceUrl}: ${formatErrorMessage(err)}`,
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!res.ok) {
|
||||
const statusText = res.statusText ? ` ${res.statusText}` : "";
|
||||
const redirected = finalUrl !== url ? ` (redirected to ${finalUrl})` : "";
|
||||
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
|
||||
let detail = `HTTP ${res.status}${statusText}`;
|
||||
if (!res.body) {
|
||||
detail = `HTTP ${res.status}${statusText}; empty response body`;
|
||||
@@ -163,7 +174,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
}
|
||||
throw new MediaFetchError(
|
||||
"http_error",
|
||||
`Failed to fetch media from ${url}${redirected}: ${detail}`,
|
||||
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +184,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new MediaFetchError(
|
||||
"max_bytes",
|
||||
`Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`,
|
||||
`Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${maxBytes}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +196,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
onOverflow: ({ maxBytes, res }) =>
|
||||
new MediaFetchError(
|
||||
"max_bytes",
|
||||
`Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`,
|
||||
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: payload exceeds maxBytes ${maxBytes}`,
|
||||
),
|
||||
chunkTimeoutMs: readIdleTimeoutMs,
|
||||
})
|
||||
@@ -196,7 +207,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
|
||||
}
|
||||
throw new MediaFetchError(
|
||||
"fetch_failed",
|
||||
`Failed to fetch media from ${res.url || url}: ${String(err)}`,
|
||||
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: ${formatErrorMessage(err)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user