diff --git a/src/slack/monitor/media.test.ts b/src/slack/monitor/media.test.ts index 822e38a9d..9a49b249e 100644 --- a/src/slack/monitor/media.test.ts +++ b/src/slack/monitor/media.test.ts @@ -289,6 +289,76 @@ describe("resolveSlackMedia", () => { expect(result).toBeNull(); }); + it("allows genuine HTML file when mimetype indicates text/html", async () => { + // Mock the store module + vi.doMock("../../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test.html", + contentType: "text/html", + }), + })); + + const { resolveSlackMedia } = await import("./media.js"); + + // A genuine HTML file shared by a user + const htmlContent = `User's HTML page`; + 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: "https://files.slack.com/page.html", + name: "page.html", + mimetype: "text/html", + }, + ], + token: "xoxb-test-token", + maxBytes: 10 * 1024 * 1024, + }); + + // Should allow genuine HTML files + expect(result).not.toBeNull(); + expect(result?.contentType).toBe("text/html"); + }); + + it("allows HTML file based on .html extension even without mimetype", async () => { + // Mock the store module + vi.doMock("../../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test.html", + contentType: "text/html", + }), + })); + + const { resolveSlackMedia } = await import("./media.js"); + + const htmlContent = `User's HTML snippet`; + const htmlResponse = new Response(htmlContent, { + status: 200, + headers: { "content-type": "text/html" }, + }); + mockFetch.mockResolvedValueOnce(htmlResponse); + + const result = await resolveSlackMedia({ + files: [ + { + url_private: "https://files.slack.com/snippet.html", + name: "snippet.html", + // No mimetype set + }, + ], + token: "xoxb-test-token", + maxBytes: 10 * 1024 * 1024, + }); + + // Should allow based on .html extension + expect(result).not.toBeNull(); + }); + it("falls through to next file when first returns HTML", async () => { // Mock the store module vi.doMock("../../media/store.js", () => ({ diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 0770fb43f..743ee50a9 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -82,8 +82,12 @@ export async function resolveSlackMedia(params: { // Guard: reject if we received HTML instead of expected media. // This happens when Slack auth fails and returns a login page. + // Skip this check if the file metadata indicates it's genuinely an HTML file. const detectedMime = fetched.contentType?.split(";")[0]?.trim().toLowerCase(); - if (detectedMime === "text/html" || looksLikeHtml(fetched.buffer)) { + const expectedMime = file.mimetype?.split(";")[0]?.trim().toLowerCase(); + const isExpectedHtml = + expectedMime === "text/html" || file.name?.toLowerCase().endsWith(".html"); + if (!isExpectedHtml && (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`,