feat(webui): fileResponseHeaders with render-mode for html preview
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fileResponseHeaders } from "./workspaceFileHeaders";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
describe("fileResponseHeaders", () => {
|
||||
it("renders html as text/html with an allow-scripts sandbox in render mode", () => {
|
||||
const h = fileResponseHeaders("html", "report.html", { render: true });
|
||||
expect(h.contentType).toBe("text/html; charset=utf-8");
|
||||
expect(h.csp).toBe("sandbox allow-scripts");
|
||||
expect(h.disposition).toMatch(/^inline;/);
|
||||
expect(h.disposition).toContain('filename="report.html"');
|
||||
});
|
||||
|
||||
it("ignores render for non-html extensions", () => {
|
||||
const h = fileResponseHeaders("md", "notes.md", { render: true });
|
||||
expect(h.contentType).toBe("text/markdown; charset=utf-8");
|
||||
expect(h.csp).toBe("sandbox");
|
||||
});
|
||||
|
||||
it("lets download take precedence over render for html", () => {
|
||||
const h = fileResponseHeaders("html", "report.html", {
|
||||
render: true,
|
||||
download: true,
|
||||
});
|
||||
expect(h.contentType).toBe("text/plain; charset=utf-8");
|
||||
expect(h.csp).toBe("sandbox");
|
||||
expect(h.disposition).toMatch(/^attachment;/);
|
||||
});
|
||||
|
||||
it("keeps plain html (no render) as text/plain with a full sandbox", () => {
|
||||
const h = fileResponseHeaders("html", "report.html", {});
|
||||
expect(h.contentType).toBe("text/plain; charset=utf-8");
|
||||
expect(h.csp).toBe("sandbox");
|
||||
expect(h.disposition).toMatch(/^inline;/);
|
||||
});
|
||||
|
||||
it("serves css and js with executable MIME types so render-mode pages can use them", () => {
|
||||
expect(fileResponseHeaders("css", "a.css", {}).contentType).toBe(
|
||||
"text/css; charset=utf-8"
|
||||
);
|
||||
expect(fileResponseHeaders("js", "a.js", {}).contentType).toBe(
|
||||
"text/javascript; charset=utf-8"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps images and pdf inline with their MIME types", () => {
|
||||
expect(fileResponseHeaders("png", "a.png", {}).contentType).toBe("image/png");
|
||||
expect(fileResponseHeaders("pdf", "a.pdf", {}).contentType).toBe("application/pdf");
|
||||
expect(fileResponseHeaders("png", "a.png", {}).disposition).toMatch(/^inline;/);
|
||||
});
|
||||
|
||||
it("forces unknown extensions to attachment octet-stream", () => {
|
||||
const h = fileResponseHeaders("bin", "a.bin", {});
|
||||
expect(h.contentType).toBe("application/octet-stream");
|
||||
expect(h.disposition).toMatch(/^attachment;/);
|
||||
});
|
||||
|
||||
it("forces download=1 to attachment for inline types", () => {
|
||||
const h = fileResponseHeaders("png", "a.png", { download: true });
|
||||
expect(h.disposition).toMatch(/^attachment;/);
|
||||
});
|
||||
|
||||
it("keeps ts/tsx as plain text (never executed)", () => {
|
||||
expect(fileResponseHeaders("ts", "a.ts", {}).contentType).toBe(
|
||||
"text/plain; charset=utf-8"
|
||||
);
|
||||
expect(fileResponseHeaders("tsx", "a.tsx", {}).contentType).toBe(
|
||||
"text/plain; charset=utf-8"
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes non-ASCII filenames per RFC 6266", () => {
|
||||
const h = fileResponseHeaders("html", "报告.html", { render: true });
|
||||
expect(h.disposition).toContain('filename="__.html"');
|
||||
expect(h.disposition).toContain("filename*=UTF-8''");
|
||||
expect(h.disposition).toContain("%E6%8A%A5%E5%91%8A");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import "server-only";
|
||||
|
||||
/** RFC 6266 Content-Disposition value with both an ASCII fallback and a UTF-8
|
||||
* `filename*` so non-ASCII names (e.g. Chinese) download with their real name
|
||||
* instead of percent-encoded gibberish. */
|
||||
function contentDisposition(fileName: string, asAttachment: boolean): string {
|
||||
const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
||||
const encoded = encodeURIComponent(fileName).replace(
|
||||
/['()*]/g,
|
||||
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
|
||||
);
|
||||
return `${
|
||||
asAttachment ? "attachment" : "inline"
|
||||
}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
// Map common research-output extensions to a Content-Type. Anything unlisted is
|
||||
// served as a download (octet-stream) so the browser never tries to execute it.
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
txt: "text/plain; charset=utf-8",
|
||||
md: "text/markdown; charset=utf-8",
|
||||
log: "text/plain; charset=utf-8",
|
||||
csv: "text/csv; charset=utf-8",
|
||||
tsv: "text/tab-separated-values; charset=utf-8",
|
||||
json: "application/json; charset=utf-8",
|
||||
py: "text/plain; charset=utf-8",
|
||||
ts: "text/plain; charset=utf-8",
|
||||
tsx: "text/plain; charset=utf-8",
|
||||
sh: "text/plain; charset=utf-8",
|
||||
yaml: "text/plain; charset=utf-8",
|
||||
yml: "text/plain; charset=utf-8",
|
||||
toml: "text/plain; charset=utf-8",
|
||||
tex: "text/plain; charset=utf-8",
|
||||
bib: "text/plain; charset=utf-8",
|
||||
html: "text/plain; charset=utf-8", // never text/html without render=1
|
||||
xml: "text/plain; charset=utf-8",
|
||||
// css/js use executable MIME types so pages loaded with ?render=1 can apply
|
||||
// their same-directory assets (nosniff blocks text/plain). Directly opening
|
||||
// them stays safe: the CSP sandbox forbids script execution there.
|
||||
css: "text/css; charset=utf-8",
|
||||
js: "text/javascript; charset=utf-8",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
bmp: "image/bmp",
|
||||
pdf: "application/pdf",
|
||||
};
|
||||
|
||||
export interface FileResponseHeaders {
|
||||
contentType: string;
|
||||
disposition: string;
|
||||
csp: string;
|
||||
}
|
||||
|
||||
export function fileResponseHeaders(
|
||||
ext: string,
|
||||
fileName: string,
|
||||
opts: { download?: boolean; render?: boolean }
|
||||
): FileResponseHeaders {
|
||||
const download = opts.download === true;
|
||||
// render=1 is honored only for html and never overrides an explicit download.
|
||||
if (ext === "html" && opts.render === true && !download) {
|
||||
return {
|
||||
contentType: "text/html; charset=utf-8",
|
||||
disposition: contentDisposition(fileName, false),
|
||||
// allow-scripts for interactive reports (plotly/echarts); no
|
||||
// allow-same-origin, so scripts can't reach the app's cookies or DOM.
|
||||
csp: "sandbox allow-scripts",
|
||||
};
|
||||
}
|
||||
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
||||
const asAttachment = download || contentType === "application/octet-stream";
|
||||
return {
|
||||
contentType,
|
||||
disposition: contentDisposition(fileName, asAttachment),
|
||||
// Workspace files are agent/user-controlled. `sandbox` neutralizes scripts
|
||||
// in an inline SVG/HTML opened directly (XSS).
|
||||
csp: "sandbox",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user