fix(media): allow host-local CSV uploads via Slack (#63604)

CSV files (text/csv) were rejected by assertHostReadMediaAllowed because
content sniffers report text/plain for CSV — CSV is structurally
indistinguishable from plain text at the byte level.

Fix:
- Add text/csv to HOST_READ_ALLOWED_DOCUMENT_MIMES
- Add a targeted exception: when sniffed MIME is text/plain AND the
  extension-derived MIME is text/csv, allow the upload. The text/plain
  sniff already confirms the content is valid UTF-8 text (not binary),
  so the .csv extension is sufficient to confirm operator intent.

Binary data disguised as .csv is still rejected because its sniffed MIME
will not be text/plain (e.g. a PNG file sniffs as image/png).

Fixes #63604
This commit is contained in:
Chen Chia Yang
2026-04-15 15:20:51 +08:00
committed by Frank Yang
parent f49d9bcae9
commit 5735772de6
2 changed files with 37 additions and 0 deletions

View File

@@ -185,6 +185,35 @@ describe("loadWebMedia", () => {
});
});
it("allows host-read CSV files", async () => {
const csvFile = path.join(fixtureRoot, "data.csv");
await fs.writeFile(csvFile, "name,value\nfoo,1\nbar,2\n", "utf8");
const result = await loadWebMedia(csvFile, {
maxBytes: 1024 * 1024,
localRoots: "any",
readFile: async (filePath) => await fs.readFile(filePath),
hostReadCapability: true,
});
expect(result.kind).toBe("document");
expect(result.contentType).toBe("text/csv");
});
it("rejects binary data disguised as a CSV file", async () => {
const fakeCsv = path.join(fixtureRoot, "evil.csv");
// Write a PNG header — binary, not text
await fs.writeFile(fakeCsv, Buffer.from(TINY_PNG_BASE64, "base64"));
await expect(
loadWebMedia(fakeCsv, {
maxBytes: 1024 * 1024,
localRoots: "any",
readFile: async (filePath) => await fs.readFile(filePath),
hostReadCapability: true,
}),
).rejects.toMatchObject({
code: "path-not-allowed",
});
});
it("rejects traversal-style canvas media paths before filesystem access", async () => {
await expect(
loadWebMedia(`${CANVAS_HOST_PATH}/documents/../collection.media/tiny.png`),

View File

@@ -83,6 +83,7 @@ const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/csv",
]);
const MB = 1024 * 1024;
@@ -133,6 +134,13 @@ function assertHostReadMediaAllowed(params: {
return;
}
const normalizedMime = normalizeMimeType(params.contentType);
// CSV exception: content sniffers report text/plain for CSV because CSV is structurally
// indistinguishable from plain text at the byte level. Allow it when:
// - The extension-derived MIME is text/csv (operator intent)
// - The content sniffed as text/plain (confirming valid text, not binary data)
if (sniffedMime === "text/plain" && normalizedMime === "text/csv") {
return;
}
if (
params.kind === "document" &&
normalizedMime &&