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.
This commit is contained in:
Yoshihiro Takahara 2026-01-30 08:19:25 +00:00
parent 699784dbee
commit da6f3e21a5
2 changed files with 111 additions and 0 deletions

View File

@ -242,6 +242,91 @@ describe("resolveSlackMedia", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects HTML response (auth failure) and returns null", async () => {
const { resolveSlackMedia } = await import("./media.js");
// Simulate Slack returning an HTML login page instead of the image
const htmlContent = `<!DOCTYPE html>
<html data-cdn="https://a.slack-edge.com/">
<head><title>Slack</title></head>
<body>
<script>redirectURL: "/files-pri/T123-F456/download/image.png"</script>
</body>
</html>`;
const htmlResponse = new Response(htmlContent, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
mockFetch.mockResolvedValueOnce(htmlResponse);
const result = await resolveSlackMedia({
files: [{ url_private_download: "https://files.slack.com/test.png", name: "test.png" }],
token: "invalid-or-expired-token",
maxBytes: 10 * 1024 * 1024,
});
// Should reject HTML and return null
expect(result).toBeNull();
});
it("rejects HTML response detected by buffer content even if content-type header is missing", async () => {
const { resolveSlackMedia } = await import("./media.js");
// HTML content but no content-type header (edge case)
const htmlContent = `<!doctype html><html><body>Slack login page</body></html>`;
const htmlResponse = new Response(htmlContent, {
status: 200,
// No content-type header
});
mockFetch.mockResolvedValueOnce(htmlResponse);
const result = await resolveSlackMedia({
files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
token: "xoxb-test-token",
maxBytes: 10 * 1024 * 1024,
});
expect(result).toBeNull();
});
it("falls through to next file when first returns HTML", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({
saveMediaBuffer: vi.fn().mockResolvedValue({
path: "/tmp/test.jpg",
contentType: "image/jpeg",
}),
}));
const { resolveSlackMedia } = await import("./media.js");
// First file: HTML (auth failure)
const htmlResponse = new Response("<!DOCTYPE html><html>login</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
// Second file: actual image
const imageResponse = new Response(Buffer.from("image data"), {
status: 200,
headers: { "content-type": "image/jpeg" },
});
mockFetch.mockResolvedValueOnce(htmlResponse).mockResolvedValueOnce(imageResponse);
const result = await resolveSlackMedia({
files: [
{ url_private: "https://files.slack.com/first.png", name: "first.png" },
{ url_private: "https://files.slack.com/second.jpg", name: "second.jpg" },
],
token: "xoxb-test-token",
maxBytes: 10 * 1024 * 1024,
});
// Should skip HTML and succeed with the second file
expect(result).not.toBeNull();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("falls through to next file when first file returns error", async () => {
// Mock the store module
vi.doMock("../../media/store.js", () => ({

View File

@ -3,8 +3,23 @@ import type { WebClient as SlackWebClient } from "@slack/web-api";
import type { FetchLike } from "../../media/fetch.js";
import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import { logWarn } from "../../logger.js";
import type { SlackFile } from "../types.js";
/**
* Detects if buffer content looks like HTML (login page / error page).
* Slack sometimes returns HTML login pages when auth fails instead of binary media.
*/
function looksLikeHtml(buffer: Buffer): boolean {
const head = buffer.subarray(0, 512).toString("utf-8").trim().toLowerCase();
return (
head.startsWith("<!doctype html") ||
head.startsWith("<html") ||
head.includes("slack-edge.com") ||
head.includes("redirecturl:")
);
}
/**
* Fetches a URL with Authorization header, handling cross-origin redirects.
* Node.js fetch strips Authorization headers on cross-origin redirects for security.
@ -64,6 +79,17 @@ export async function resolveSlackMedia(params: {
filePathHint: file.name,
});
if (fetched.buffer.byteLength > params.maxBytes) continue;
// Guard: reject if we received HTML instead of expected media.
// This happens when Slack auth fails and returns a login page.
const detectedMime = fetched.contentType?.split(";")[0]?.trim().toLowerCase();
if (detectedMime === "text/html" || looksLikeHtml(fetched.buffer)) {
const fileId = file.name ?? file.id ?? "unknown";
logWarn(
`slack: received HTML instead of media for file ${fileId}; possible auth failure or expired URL`,
);
continue;
}
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? file.mimetype,