diff --git a/src/server/files/kv-transit-files.test.ts b/src/server/files/kv-transit-files.test.ts index 3c3597e5..4852e252 100644 --- a/src/server/files/kv-transit-files.test.ts +++ b/src/server/files/kv-transit-files.test.ts @@ -138,6 +138,19 @@ describe("KVTransitFileService", () => { expect(response.headers.get("content-disposition")).toBe('attachment; filename="a_b_c_d_e.txt"'); }); + it("serves a file whose name is outside Latin-1", async () => { + const namespace = new MemoryKVNamespace(); + const service = createService(namespace); + const upload = await service.create(new File(["ok"], "发票.pdf", { type: "application/pdf" })); + + const response = await service.response(upload.fileId); + + expect(response.status).toBe(200); + expect(response.headers.get("content-disposition")).toBe( + "attachment; filename=\"__.pdf\"; filename*=UTF-8''%E5%8F%91%E7%A5%A8.pdf", + ); + }); + it("rejects non-integer, non-positive, or non-finite ttl/maxBytes at construction", () => { // NaN maxBytes would otherwise slip past Math.min and disable the size check entirely. for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1, 1.5]) { diff --git a/src/server/files/kv-transit-files.ts b/src/server/files/kv-transit-files.ts index 7a4b7d5d..eed92ee5 100644 --- a/src/server/files/kv-transit-files.ts +++ b/src/server/files/kv-transit-files.ts @@ -2,7 +2,7 @@ import type { KVNamespaceBinding } from "../cloudflare/cloudflare-bindings.ts"; import type { ITransitFileService, TransitFileRead, TransitFileUpload } from "./transit-file-store.ts"; import { extname } from "node:path"; -import { contentTypeFromFileId, TransitFileError } from "./transit-file-store.ts"; +import { contentDispositionForFileName, contentTypeFromFileId, TransitFileError } from "./transit-file-store.ts"; // Workers KV rejects an `expirationTtl` below 60 seconds. const KV_MIN_TTL_SECONDS = 60; @@ -83,7 +83,7 @@ export class KVTransitFileService implements ITransitFileService { headers: { "content-length": String(metadata.sizeBytes), "content-type": metadata.mimeType, - "content-disposition": `attachment; filename="${escapeHeaderValue(metadata.name)}"`, + "content-disposition": contentDispositionForFileName(metadata.name), }, }); } @@ -151,9 +151,6 @@ function positiveInteger(value: number, field: string): number { } return value; } -function escapeHeaderValue(value: string): string { - return value.replace(/["\\\r\n]/g, "_"); -} function safeExtension(name: string): string { const extension = extname(name).toLowerCase(); return /^\.[a-z0-9]{1,16}$/.test(extension) ? extension : ""; diff --git a/src/server/files/r2-transit-files.ts b/src/server/files/r2-transit-files.ts index c830e69c..4d4efaa0 100644 --- a/src/server/files/r2-transit-files.ts +++ b/src/server/files/r2-transit-files.ts @@ -2,7 +2,7 @@ import type { R2BucketBinding, R2ObjectBinding } from "../cloudflare/cloudflare- import type { ITransitFileService, TransitFileRead, TransitFileUpload } from "./transit-file-store.ts"; import { extname } from "node:path"; -import { contentTypeFromFileId, TransitFileError } from "./transit-file-store.ts"; +import { contentDispositionForFileName, contentTypeFromFileId, TransitFileError } from "./transit-file-store.ts"; export interface R2TransitFileOptions { bucket: R2BucketBinding; @@ -71,7 +71,7 @@ export class R2TransitFileService implements ITransitFileService { headers: { "content-length": String(metadata.sizeBytes), "content-type": metadata.mimeType, - "content-disposition": `attachment; filename="${escapeHeaderValue(metadata.name)}"`, + "content-disposition": contentDispositionForFileName(metadata.name), }, }); } @@ -151,10 +151,6 @@ function assertSafeFileId(fileId: string): void { } } -function escapeHeaderValue(value: string): string { - return value.replace(/["\\\r\n]/g, "_"); -} - function safeExtension(name: string): string { const extension = extname(name).toLowerCase(); return /^\.[a-z0-9]{1,16}$/.test(extension) ? extension : ""; diff --git a/src/server/files/transit-file-store.test.ts b/src/server/files/transit-file-store.test.ts new file mode 100644 index 00000000..5169dfbf --- /dev/null +++ b/src/server/files/transit-file-store.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { contentDispositionForFileName, createTransitFileResponse } from "./transit-file-store.ts"; + +describe("contentDispositionForFileName", () => { + it("leaves an ASCII file name in the plain filename parameter", () => { + expect(contentDispositionForFileName("report.TXT")).toBe('attachment; filename="report.TXT"'); + }); + + it("escapes quotes, backslashes, and control bytes without adding an encoded parameter", () => { + expect(contentDispositionForFileName('a"b\\c\rd\ne.txt')).toBe('attachment; filename="a_b_c_d_e.txt"'); + }); + + it("carries a non-ASCII file name in filename* with an ASCII fallback", () => { + expect(contentDispositionForFileName("发票.pdf")).toBe( + "attachment; filename=\"__.pdf\"; filename*=UTF-8''%E5%8F%91%E7%A5%A8.pdf", + ); + }); + + it("encodes characters that are percent-encoded but not attr-char", () => { + expect(contentDispositionForFileName("i'nvoice(1)*.pdf—x")).toBe( + "attachment; filename=\"i'nvoice(1)*.pdf_x\"; filename*=UTF-8''i%27nvoice%281%29%2A.pdf%E2%80%94x", + ); + }); + + it("counts an astral character as a single replacement", () => { + expect(contentDispositionForFileName("chart\u{1f4ca}.pdf")).toBe( + "attachment; filename=\"chart_.pdf\"; filename*=UTF-8''chart%F0%9F%93%8A.pdf", + ); + }); +}); + +describe("createTransitFileResponse", () => { + it("builds a response for a file whose name is outside Latin-1", async () => { + const response = createTransitFileResponse({ + file: new File(["hello"], "发票.pdf", { type: "application/pdf" }), + sizeBytes: 5, + name: "发票.pdf", + mimeType: "application/pdf", + }); + + expect(response.headers.get("content-disposition")).toBe( + "attachment; filename=\"__.pdf\"; filename*=UTF-8''%E5%8F%91%E7%A5%A8.pdf", + ); + expect(await response.text()).toBe("hello"); + }); +}); diff --git a/src/server/files/transit-file-store.ts b/src/server/files/transit-file-store.ts index 20ec985e..ad55a539 100644 --- a/src/server/files/transit-file-store.ts +++ b/src/server/files/transit-file-store.ts @@ -38,11 +38,36 @@ export function createTransitFileResponse(file: TransitFileRead): Response { headers: { "content-length": String(file.sizeBytes), "content-type": file.mimeType, - "content-disposition": `attachment; filename="${escapeHeaderValue(file.name)}"`, + "content-disposition": contentDispositionForFileName(file.name), }, }); } +/** + * Build the `content-disposition` value for a transit file download. + * + * Header values are ByteStrings, so a name holding a character above U+00FF + * throws while the response is constructed and the download fails. Such names + * travel in the RFC 6266 `filename*` parameter, and `filename` keeps an + * ASCII-only form for clients that do not read `filename*`. + */ +export function contentDispositionForFileName(name: string): string { + const asciiName = name.replace(/[^\u0020-\u007e]/gu, "_").replace(/["\\]/g, "_"); + if (!/[\u0080-\u{10ffff}]/u.test(name)) { + return `attachment; filename="${asciiName}"`; + } + + return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeExtendedValue(name)}`; +} + +/** Percent-encode a file name as an RFC 8187 `ext-value`, which allows fewer literals than a URI component. */ +function encodeExtendedValue(name: string): string { + return encodeURIComponent(name).replace( + /['()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + export function contentTypeFromFileId(fileId: string): string { const dotIndex = fileId.lastIndexOf("."); const extension = dotIndex === -1 ? "" : fileId.slice(dotIndex).toLowerCase(); @@ -94,7 +119,3 @@ export function contentTypeFromFileId(fileId: string): string { return "application/octet-stream"; } } - -function escapeHeaderValue(value: string): string { - return value.replace(/["\\\r\n]/g, "_"); -} diff --git a/src/server/files/transit-files.ts b/src/server/files/transit-files.ts index bed8c9d9..18878d5d 100644 --- a/src/server/files/transit-files.ts +++ b/src/server/files/transit-files.ts @@ -7,7 +7,7 @@ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node: import { extname, join } from "node:path"; import { Readable } from "node:stream"; import { finished } from "node:stream/promises"; -import { contentTypeFromFileId, TransitFileError } from "./transit-file-store.ts"; +import { contentDispositionForFileName, contentTypeFromFileId, TransitFileError } from "./transit-file-store.ts"; export interface TransitFileOptions { rootDir: string; @@ -97,7 +97,7 @@ export class TransitFileService implements ITransitFileService { headers: { "content-length": String(stats.size), "content-type": metadata.mimeType, - "content-disposition": `attachment; filename="${escapeHeaderValue(metadata.name)}"`, + "content-disposition": contentDispositionForFileName(metadata.name), }, }); } @@ -218,7 +218,3 @@ function normalizeMetadata( typeof input.mimeType === "string" && input.mimeType.trim() ? input.mimeType.trim() : fallback.mimeType; return { name, mimeType }; } - -function escapeHeaderValue(value: string): string { - return value.replace(/["\\\r\n]/g, "_"); -}