Skip to content

Commit e057139

Browse files
tumfTakhoffman
andauthored
fix(slack): reject HTML responses when downloading media (openclaw#4665)
* fix(slack): reject HTML responses when downloading media Slack sometimes returns HTML login pages instead of binary media when authentication fails or URLs expire. This change detects HTML responses by checking content-type header and buffer content, then skips to the next available file URL. * fix: format import order and add braces to continue statement * chore: format Slack media tests * chore: apply formatter to Slack media tests * fix(slack): merge auth-header forwarding and html media guard --------- Co-authored-by: Tak Hoffman <[email protected]>
1 parent 6dbbc58 commit e057139

File tree

3 files changed

+73
-0
lines changed

3 files changed

+73
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Docs: https://docs.openclaw.ai
8484
### Fixes
8585

8686
- Slack/Bot attachment-only messages: when `allowBots: true`, bot messages with empty `text` now include non-forwarded attachment `text`/`fallback` content so webhook alerts are not silently dropped. (#27616)
87+
- Slack/Inbound media auth + HTML guard: keep Slack auth headers on forwarded shared attachment image downloads, and reject login/error HTML payloads (while allowing expected `.html` uploads) when resolving Slack media so auth failures do not silently pass as files. (#18642)
8788
- Slack/Security ingress mismatch guard: drop slash-command and interaction payloads when app/team identifiers do not match the active Slack account context (including nested `team.id` interaction payloads), preventing cross-app or cross-workspace payload injection into system-event handling. (#29091) Thanks @Solvely-Colin.
8889
- Cron/Failure alerts: add configurable repeated-failure alerting with per-job overrides and Web UI cron editor support (`inherit|disabled|custom` with threshold/cooldown/channel/target fields). (#24789) Thanks xbrak.
8990
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.

src/slack/monitor/media.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,52 @@ describe("resolveSlackMedia", () => {
245245
expect(mockFetch).not.toHaveBeenCalled();
246246
});
247247

248+
it("rejects HTML auth pages for non-HTML files", async () => {
249+
const saveMediaBufferMock = vi.spyOn(mediaStore, "saveMediaBuffer");
250+
mockFetch.mockResolvedValueOnce(
251+
new Response("<!DOCTYPE html><html><body>login</body></html>", {
252+
status: 200,
253+
headers: { "content-type": "text/html; charset=utf-8" },
254+
}),
255+
);
256+
257+
const result = await resolveSlackMedia({
258+
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
259+
token: "xoxb-test-token",
260+
maxBytes: 1024 * 1024,
261+
});
262+
263+
expect(result).toBeNull();
264+
expect(saveMediaBufferMock).not.toHaveBeenCalled();
265+
});
266+
267+
it("allows expected HTML uploads", async () => {
268+
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue(
269+
createSavedMedia("/tmp/page.html", "text/html"),
270+
);
271+
mockFetch.mockResolvedValueOnce(
272+
new Response("<!doctype html><html><body>ok</body></html>", {
273+
status: 200,
274+
headers: { "content-type": "text/html" },
275+
}),
276+
);
277+
278+
const result = await resolveSlackMedia({
279+
files: [
280+
{
281+
url_private: "https://files.slack.com/page.html",
282+
name: "page.html",
283+
mimetype: "text/html",
284+
},
285+
],
286+
token: "xoxb-test-token",
287+
maxBytes: 1024 * 1024,
288+
});
289+
290+
expect(result).not.toBeNull();
291+
expect(result?.[0]?.path).toBe("/tmp/page.html");
292+
});
293+
248294
it("overrides video/* MIME to audio/* for slack_audio voice messages", async () => {
249295
// saveMediaBuffer re-detects MIME from buffer bytes, so it may return
250296
// video/mp4 for MP4 containers. Verify resolveSlackMedia preserves
@@ -525,6 +571,11 @@ describe("resolveSlackAttachmentContent", () => {
525571
},
526572
],
527573
});
574+
const firstCall = mockFetch.mock.calls[0];
575+
expect(firstCall?.[0]).toBe("https://files.slack.com/forwarded.jpg");
576+
const firstInit = firstCall?.[1];
577+
expect(firstInit?.redirect).toBe("manual");
578+
expect(new Headers(firstInit?.headers).get("Authorization")).toBe("Bearer xoxb-test-token");
528579
});
529580
});
530581

src/slack/monitor/media.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ function resolveSlackMediaMimetype(
125125
return mime;
126126
}
127127

128+
function looksLikeHtmlBuffer(buffer: Buffer): boolean {
129+
const head = buffer.subarray(0, 512).toString("utf-8").replace(/^\s+/, "").toLowerCase();
130+
return head.startsWith("<!doctype html") || head.startsWith("<html");
131+
}
132+
128133
export type SlackMediaResult = {
129134
path: string;
130135
contentType?: string;
@@ -217,6 +222,20 @@ export async function resolveSlackMedia(params: {
217222
if (fetched.buffer.byteLength > params.maxBytes) {
218223
return null;
219224
}
225+
226+
// Guard against auth/login HTML pages returned instead of binary media.
227+
// Allow user-provided HTML files through.
228+
const fileMime = file.mimetype?.toLowerCase();
229+
const fileName = file.name?.toLowerCase() ?? "";
230+
const isExpectedHtml =
231+
fileMime === "text/html" || fileName.endsWith(".html") || fileName.endsWith(".htm");
232+
if (!isExpectedHtml) {
233+
const detectedMime = fetched.contentType?.split(";")[0]?.trim().toLowerCase();
234+
if (detectedMime === "text/html" || looksLikeHtmlBuffer(fetched.buffer)) {
235+
return null;
236+
}
237+
}
238+
220239
const effectiveMime = resolveSlackMediaMimetype(file, fetched.contentType);
221240
const saved = await saveMediaBuffer(
222241
fetched.buffer,
@@ -273,8 +292,10 @@ export async function resolveSlackAttachmentContent(params: {
273292
const imageUrl = resolveForwardedAttachmentImageUrl(att);
274293
if (imageUrl) {
275294
try {
295+
const fetchImpl = createSlackMediaFetch(params.token);
276296
const fetched = await fetchRemoteMedia({
277297
url: imageUrl,
298+
fetchImpl,
278299
maxBytes: params.maxBytes,
279300
});
280301
if (fetched.buffer.byteLength <= params.maxBytes) {

0 commit comments

Comments
 (0)