server/browser-upload.test.ts
Rama main · réplica de lectura del repositorio original.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, sep } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { namedUploadPaths, uploadableFiles } from "./browser-upload.ts";
import { removeTempDir } from "./testing/cleanup.ts";
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) removeTempDir(dir); });
function folders() {
const base = realpathSync.native(mkdtempSync(join(tmpdir(), "omb-upload-")));
dirs.push(base);
const attachments = join(base, "attachments"), workspace = join(base, "workspace"), outside = join(base, "outside");
for (const dir of [attachments, workspace, outside]) mkdirSync(dir);
writeFileSync(join(attachments, "cv.pdf"), "%PDF");
writeFileSync(join(workspace, "report.csv"), "a,b");
writeFileSync(join(outside, "secret.txt"), "no");
return { base, attachments, workspace, outside, roots: [attachments, workspace] };
}
describe("files a page may receive", () => {
it("takes the person's attachments and the bot's own folders", () => {
const { attachments, workspace, roots } = folders();
expect(uploadableFiles([join(attachments, "cv.pdf"), join(workspace, "report.csv")], roots))
.toEqual({ files: [join(attachments, "cv.pdf"), join(workspace, "report.csv")] });
});
it("refuses any other file, including one reached through a symlink or a ..", () => {
const { attachments, outside, roots } = folders();
symlinkSync(join(outside, "secret.txt"), join(attachments, "looks-attached.txt"));
for (const file of [join(outside, "secret.txt"), join(attachments, "looks-attached.txt"), join(attachments, "..", "outside", "secret.txt"), process.execPath]) {
expect(uploadableFiles([file], roots)).toEqual({ error: expect.stringMatching(/cannot be uploaded/) });
}
});
it("refuses relative, missing and folder paths, and more than ten files", () => {
const { attachments, roots } = folders();
expect(uploadableFiles(["cv.pdf"], roots)).toEqual({ error: expect.stringMatching(/not an absolute path/) });
expect(uploadableFiles([join(attachments, "gone.pdf")], roots)).toEqual({ error: expect.stringMatching(/does not exist/) });
mkdirSync(join(attachments, "folder"));
expect(uploadableFiles([join(attachments, "folder")], roots)).toEqual({ error: expect.stringMatching(/not a file/) });
expect(uploadableFiles(Array(11).fill(join(attachments, "cv.pdf")), roots)).toEqual({ error: expect.stringMatching(/1 to 10/) });
expect(uploadableFiles([], roots)).toEqual({ error: expect.stringMatching(/1 to 10/) });
expect(uploadableFiles("cv.pdf", roots)).toEqual({ error: expect.stringMatching(/1 to 10/) });
});
});
describe("the name a page sees", () => {
it("gives an attachment the person's own name, and leaves every other file as it is", async () => {
const { base, attachments, workspace } = folders();
const stored = join(attachments, "cv.pdf"), report = join(workspace, "report.csv");
const [named, same, unnamed, unsafe] = await namedUploadPaths([
{ file: stored, name: "Jane Doe CV.pdf" }, { file: report, name: "report.csv" }, { file: report, name: null }, { file: stored, name: "../escape.pdf" },
], join(base, "uploads"));
expect(basename(named!)).toBe("Jane Doe CV.pdf");
expect(dirname(dirname(named!))).toBe(join(base, "uploads"));
expect(readFileSync(named!, "utf8")).toBe("%PDF");
expect([same, unnamed, unsafe]).toEqual([report, report, stored]);
});
it("removes named copies a day old", async () => {
const { base, attachments } = folders();
const folder = join(base, "uploads");
const [old] = await namedUploadPaths([{ file: join(attachments, "cv.pdf"), name: "old.pdf" }], folder);
const day = 24 * 60 * 60 * 1000;
utimesSync(dirname(old!), new Date(Date.now() - day - 1000), new Date(Date.now() - day - 1000));
const [fresh] = await namedUploadPaths([{ file: join(attachments, "cv.pdf"), name: "new.pdf" }], folder);
expect(existsSync(old!)).toBe(false);
expect(existsSync(fresh!)).toBe(true);
});
it.each([0, 3, 1024 * 1024 + 7])("keeps %i copied bytes independent of later writes to the source", async (size) => {
const { base, workspace } = folders();
const file = join(workspace, "report.csv");
writeFileSync(file, Buffer.alloc(size, 0x61));
const [staged] = await namedUploadPaths([{ file, name: "Report.csv" }], join(base, "uploads"));
writeFileSync(file, "changed");
expect(readFileSync(staged!).equals(Buffer.alloc(size, 0x61))).toBe(true);
});
it.skipIf(process.platform === "win32")("refuses a file replaced by a symlink after the allowlist check", async () => {
const { base, workspace, outside, roots } = folders();
const file = join(workspace, "report.csv");
const checked = uploadableFiles([file], roots);
expect(checked).toEqual({ files: [file] });
if ("error" in checked) throw new Error(checked.error);
rmSync(file);
symlinkSync(join(outside, "secret.txt"), file);
await expect(namedUploadPaths(checked.files.map((file) => ({ file, name: null })), join(base, "uploads"), Date.now(), { everyFile: true })).rejects.toThrow();
});
});
describe("the files a boxed browser is given", () => {
it("copies every file into the one folder the box shows Chrome, under the person's name or its own", async () => {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), "omb-boxed-upload-")));
const source = join(root, "work", "report.txt"), stored = join(root, "attachments", "a1b2.pdf");
mkdirSync(dirname(source), { recursive: true });
mkdirSync(dirname(stored), { recursive: true });
writeFileSync(source, "report");
writeFileSync(stored, "pdf");
const folder = join(root, "profile", "omb-uploads");
const linked = await namedUploadPaths([{ file: source, name: null }, { file: stored, name: "Invoice.pdf" }], folder, Date.now(), { everyFile: true });
expect(linked.every((path) => path.startsWith(folder + sep))).toBe(true);
expect(linked.map((path) => basename(path))).toEqual(["report.txt", "Invoice.pdf"]);
expect(readFileSync(linked[0]!, "utf8")).toBe("report");
// Unboxed, a file already under its own name is given as it is.
expect(await namedUploadPaths([{ file: source, name: null }], folder)).toEqual([source]);
rmSync(root, { recursive: true, force: true });
});
});