Skip to content

Commit 4e4ed2e

Browse files
davidiachsteipete
andauthored
fix(security): cap Slack media downloads and validate Slack file URLs (#6639)
* Security: cap Slack media downloads and validate Slack file URLs * Security: relax web media fetch cap for compression * Fixes: sync pi-coding-agent options * Fixes: align system prompt override type * Slack: clarify fetchImpl assumptions * fix: respect raw media fetch cap (#6639) (thanks @davidiach) --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 521b121 commit 4e4ed2e

File tree

6 files changed

+97
-14
lines changed

6 files changed

+97
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
2525

2626
- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.
2727
- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings.
28+
- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.
2829
- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso.
2930
- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
3031
- Agents: ensure OpenRouter attribution headers apply in the embedded runner.

src/agents/pi-embedded-runner/system-prompt.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,8 @@ export function buildEmbeddedSystemPrompt(params: {
7474
});
7575
}
7676

77-
export function createSystemPromptOverride(
78-
systemPrompt: string,
79-
): (defaultPrompt?: string) => string {
80-
const trimmed = systemPrompt.trim();
81-
return () => trimmed;
77+
export function createSystemPromptOverride(systemPrompt: string): string {
78+
return systemPrompt.trim();
8279
}
8380

8481
export function applySystemPromptOverrideToSession(

src/slack/monitor/media.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,17 @@ describe("fetchWithSlackAuth", () => {
4040
});
4141
});
4242

43+
it("rejects non-Slack hosts to avoid leaking tokens", async () => {
44+
const { fetchWithSlackAuth } = await import("./media.js");
45+
46+
await expect(
47+
fetchWithSlackAuth("https://example.com/test.jpg", "xoxb-test-token"),
48+
).rejects.toThrow(/non-Slack host|non-Slack/i);
49+
50+
// Should fail fast without attempting a fetch.
51+
expect(mockFetch).not.toHaveBeenCalled();
52+
});
53+
4354
it("follows redirects without Authorization header", async () => {
4455
const { fetchWithSlackAuth } = await import("./media.js");
4556

src/slack/monitor/media.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,57 @@ import type { SlackFile } from "../types.js";
44
import { fetchRemoteMedia } from "../../media/fetch.js";
55
import { saveMediaBuffer } from "../../media/store.js";
66

7+
function normalizeHostname(hostname: string): string {
8+
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
9+
if (normalized.startsWith("[") && normalized.endsWith("]")) {
10+
return normalized.slice(1, -1);
11+
}
12+
return normalized;
13+
}
14+
15+
function isSlackHostname(hostname: string): boolean {
16+
const normalized = normalizeHostname(hostname);
17+
if (!normalized) {
18+
return false;
19+
}
20+
// Slack-hosted files typically come from *.slack.com and redirect to Slack CDN domains.
21+
// Include a small allowlist of known Slack domains to avoid leaking tokens if a file URL
22+
// is ever spoofed or mishandled.
23+
const allowedSuffixes = ["slack.com", "slack-edge.com", "slack-files.com"];
24+
return allowedSuffixes.some(
25+
(suffix) => normalized === suffix || normalized.endsWith(`.${suffix}`),
26+
);
27+
}
28+
29+
function assertSlackFileUrl(rawUrl: string): URL {
30+
let parsed: URL;
31+
try {
32+
parsed = new URL(rawUrl);
33+
} catch {
34+
throw new Error(`Invalid Slack file URL: ${rawUrl}`);
35+
}
36+
if (parsed.protocol !== "https:") {
37+
throw new Error(`Refusing Slack file URL with non-HTTPS protocol: ${parsed.protocol}`);
38+
}
39+
if (!isSlackHostname(parsed.hostname)) {
40+
throw new Error(
41+
`Refusing to send Slack token to non-Slack host "${parsed.hostname}" (url: ${rawUrl})`,
42+
);
43+
}
44+
return parsed;
45+
}
46+
747
/**
848
* Fetches a URL with Authorization header, handling cross-origin redirects.
949
* Node.js fetch strips Authorization headers on cross-origin redirects for security.
10-
* Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that
11-
* don't need the Authorization header, so we handle the initial auth request manually.
50+
* Slack's file URLs redirect to CDN domains with pre-signed URLs that don't need the
51+
* Authorization header, so we handle the initial auth request manually.
1252
*/
1353
export async function fetchWithSlackAuth(url: string, token: string): Promise<Response> {
54+
const parsed = assertSlackFileUrl(url);
55+
1456
// Initial request with auth and manual redirect handling
15-
const initialRes = await fetch(url, {
57+
const initialRes = await fetch(parsed.href, {
1658
headers: { Authorization: `Bearer ${token}` },
1759
redirect: "manual",
1860
});
@@ -29,11 +71,16 @@ export async function fetchWithSlackAuth(url: string, token: string): Promise<Re
2971
}
3072

3173
// Resolve relative URLs against the original
32-
const resolvedUrl = new URL(redirectUrl, url).toString();
74+
const resolvedUrl = new URL(redirectUrl, parsed.href);
75+
76+
// Only follow safe protocols (we do NOT include Authorization on redirects).
77+
if (resolvedUrl.protocol !== "https:") {
78+
return initialRes;
79+
}
3380

3481
// Follow the redirect without the Authorization header
3582
// (Slack's CDN URLs are pre-signed and don't need it)
36-
return fetch(resolvedUrl, { redirect: "follow" });
83+
return fetch(resolvedUrl.toString(), { redirect: "follow" });
3784
}
3885

3986
export async function resolveSlackMedia(params: {
@@ -52,8 +99,9 @@ export async function resolveSlackMedia(params: {
5299
continue;
53100
}
54101
try {
55-
// Note: We ignore init options because fetchWithSlackAuth handles
56-
// redirect behavior specially. fetchRemoteMedia only passes the URL.
102+
// Note: fetchRemoteMedia calls fetchImpl(url) with the URL string today and
103+
// handles size limits internally. We ignore init options because
104+
// fetchWithSlackAuth handles redirect/auth behavior specially.
57105
const fetchImpl: FetchLike = (input) => {
58106
const inputUrl =
59107
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
@@ -63,6 +111,7 @@ export async function resolveSlackMedia(params: {
63111
url,
64112
fetchImpl,
65113
filePathHint: file.name,
114+
maxBytes: params.maxBytes,
66115
});
67116
if (fetched.buffer.byteLength > params.maxBytes) {
68117
continue;

src/web/media.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import sharp from "sharp";
55
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { optimizeImageToPng } from "../media/image-ops.js";
7-
import { loadWebMedia, optimizeImageToJpeg } from "./media.js";
7+
import { loadWebMedia, loadWebMediaRaw, optimizeImageToJpeg } from "./media.js";
88

99
const tmpFiles: string[] = [];
1010

@@ -106,6 +106,22 @@ describe("web media loading", () => {
106106
fetchMock.mockRestore();
107107
});
108108

109+
it("respects maxBytes for raw URL fetches", async () => {
110+
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
111+
ok: true,
112+
body: true,
113+
arrayBuffer: async () => Buffer.alloc(2048).buffer,
114+
headers: { get: () => "image/png" },
115+
status: 200,
116+
} as Response);
117+
118+
await expect(loadWebMediaRaw("https://example.com/too-big.png", 1024)).rejects.toThrow(
119+
/exceeds maxBytes 1024/i,
120+
);
121+
122+
fetchMock.mockRestore();
123+
});
124+
109125
it("uses content-disposition filename when available", async () => {
110126
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
111127
ok: true,

src/web/media.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,16 @@ async function loadWebMediaInternal(
200200
};
201201

202202
if (/^https?:\/\//i.test(mediaUrl)) {
203-
const fetched = await fetchRemoteMedia({ url: mediaUrl });
203+
// Enforce a download cap during fetch to avoid unbounded memory usage.
204+
// For optimized images, allow fetching larger payloads before compression.
205+
const defaultFetchCap = maxBytesForKind("unknown");
206+
const fetchCap =
207+
maxBytes === undefined
208+
? defaultFetchCap
209+
: optimizeImages
210+
? Math.max(maxBytes, defaultFetchCap)
211+
: maxBytes;
212+
const fetched = await fetchRemoteMedia({ url: mediaUrl, maxBytes: fetchCap });
204213
const { buffer, contentType, fileName } = fetched;
205214
const kind = mediaKindFromMime(contentType);
206215
return await clampAndFinalize({ buffer, contentType, kind, fileName });

0 commit comments

Comments
 (0)