server/browser-upload-security.test.ts
Rama main · réplica de lectura del repositorio original.
import * as fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { FILE_MAX_BYTES } from "../shared/upload-limits.ts";
const hooks = vi.hoisted(() => ({
beforeRead: null as ((input: number) => void) | null,
afterOpen: null as ((path: fs.PathLike) => void) | null,
openFlags: null as ((path: fs.PathLike, flags: fs.OpenMode) => fs.OpenMode) | null,
readlink: null as ((path: fs.PathLike) => string) | null,
}));
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
...original,
openSync: (...args: Parameters<typeof original.openSync>) => {
const input = original.openSync(args[0], hooks.openFlags?.(args[0], args[1]) ?? args[1], args[2]);
hooks.afterOpen?.(args[0]);
return input;
},
readlinkSync: (...args: Parameters<typeof original.readlinkSync>) => hooks.readlink?.(args[0]) ?? original.readlinkSync(...args),
readSync: (...args: Parameters<typeof original.readSync>) => {
hooks.beforeRead?.(args[0]);
return original.readSync(...args);
},
read: Object.defineProperties((...args: Parameters<typeof original.read>) => {
hooks.beforeRead?.(args[0]);
return original.read(...args);
}, Object.getOwnPropertyDescriptors(original.read)),
};
});
const { namedUploadPaths, uploadableFiles } = await import("./browser-upload.ts");
const dirs: string[] = [];
afterEach(() => {
hooks.beforeRead = null;
hooks.afterOpen = null;
hooks.openFlags = null;
hooks.readlink = null;
vi.unstubAllGlobals();
for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
it.each(["darwin", "linux"])("stages a regular file with valid %s open flags", async (platform) => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-flags-")));
dirs.push(base);
const source = join(base, "report.txt");
fs.writeFileSync(source, "report");
vi.stubGlobal("process", { ...process, platform });
const flags: fs.OpenMode[] = [];
if (platform === "linux") hooks.readlink = () => source;
hooks.openFlags = (path, mode) => {
if (path !== source) return mode;
flags.push(mode);
if (platform === "darwin" && typeof mode === "number") {
if ((mode & 0x20000000) && (mode & fs.constants.O_NOFOLLOW)) throw Object.assign(new Error("EINVAL"), { code: "EINVAL" });
return (mode & ~0x20000000) | fs.constants.O_NOFOLLOW;
}
return mode;
};
const [staged] = await namedUploadPaths([{ file: source, name: "Report.txt" }], join(base, "uploads"));
expect(flags).toEqual([platform === "darwin" ? fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | 0x20000000
: fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW]);
expect(fs.readFileSync(staged!, "utf8")).toBe("report");
});
it.each([false, true])("stages workspace files on Windows from the checked path (everyFile: %s)", async (everyFile) => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-windows-")));
dirs.push(base);
const source = join(base, "report.txt"), folder = join(base, "uploads");
fs.writeFileSync(source, "report");
vi.stubGlobal("process", { ...process, platform: "win32" });
const flags: fs.OpenMode[] = [];
hooks.openFlags = (path, mode) => {
if (path === source) flags.push(mode);
return mode;
};
const [staged] = await namedUploadPaths([{ file: source, name: everyFile ? null : "Report.txt" }], folder, Date.now(), { everyFile });
expect(flags).toEqual([fs.constants.O_RDONLY]);
expect(staged).not.toBe(source);
expect(fs.readFileSync(staged!, "utf8")).toBe("report");
});
it.skipIf(process.platform === "win32")("pins both sources before a directory is swapped while the first file copies", async () => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-race-")));
dirs.push(base);
const workspace = join(base, "workspace"), sub = join(workspace, "sub"), outside = join(base, "outside");
fs.mkdirSync(sub, { recursive: true });
fs.mkdirSync(outside);
fs.writeFileSync(join(workspace, "large.bin"), Buffer.alloc(1024 * 1024 + 1));
fs.writeFileSync(join(sub, "report.txt"), "report");
fs.writeFileSync(join(outside, "report.txt"), "outside secret");
const checked = uploadableFiles([join(workspace, "large.bin"), join(sub, "report.txt")], [workspace]);
if ("error" in checked) throw new Error(checked.error);
hooks.beforeRead = () => {
hooks.beforeRead = null;
fs.rmSync(sub, { recursive: true });
fs.symlinkSync(outside, sub, "dir");
};
const staged = await namedUploadPaths(checked.files.map((file) => ({ file, name: null })), join(base, "uploads"), Date.now(), { everyFile: true });
expect(fs.readFileSync(staged[1]!, "utf8")).toBe("report");
});
it.skipIf(process.platform === "win32")("removes every directory from a batch when its second file fails", async () => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-cleanup-")));
dirs.push(base);
const first = join(base, "first.txt"), second = join(base, "second.txt"), folder = join(base, "uploads");
fs.writeFileSync(first, "first");
fs.writeFileSync(second, "second");
const secondInode = fs.statSync(second, { bigint: true }).ino;
hooks.beforeRead = (input) => {
if (fs.fstatSync(input, { bigint: true }).ino === secondInode) fs.truncateSync(second, 0);
};
await expect(async () => namedUploadPaths([
{ file: first, name: "First.txt" }, { file: second, name: "Second.txt" },
], folder)).rejects.toThrow(/changed/);
expect(fs.readdirSync(folder)).toEqual([]);
});
it.skipIf(process.platform === "win32")("rejects an oversized sparse file before reading it", async () => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-size-")));
dirs.push(base);
const source = join(base, "large.bin");
fs.writeFileSync(source, "");
fs.truncateSync(source, FILE_MAX_BYTES + 1);
let read = false;
hooks.beforeRead = () => { read = true; };
await expect(async () => namedUploadPaths([{ file: source, name: "Large.bin" }], join(base, "uploads"))).rejects.toThrow(/limit/i);
expect(read).toBe(false);
});
it.skipIf(process.platform === "win32")("rejects a batch over 100 MiB before reading any file", async () => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-batch-")));
dirs.push(base);
const files = Array.from({ length: 5 }, (_, i) => {
const file = join(base, `${i}.bin`);
fs.writeFileSync(file, "");
fs.truncateSync(file, 21 * 1024 * 1024);
return { file, name: `Upload-${i}.bin` };
});
let read = false;
hooks.beforeRead = () => { read = true; };
await expect(async () => namedUploadPaths(files, join(base, "uploads"))).rejects.toThrow(/limit/i);
expect(read).toBe(false);
});
it.skipIf(process.platform === "win32")("lets the event loop run while an accepted upload copies", async () => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-async-")));
dirs.push(base);
const source = join(base, "large.bin");
fs.writeFileSync(source, Buffer.alloc(2 * 1024 * 1024, 0x61));
let yielded = false;
setImmediate(() => { yielded = true; });
const [staged] = await namedUploadPaths([{ file: source, name: "Large.bin" }], join(base, "uploads"));
expect(yielded).toBe(true);
expect(fs.readFileSync(staged!)).toEqual(Buffer.alloc(2 * 1024 * 1024, 0x61));
});
it.skipIf(process.platform === "win32")("rejects a directory symlink inserted after the allowlist check before staging", async () => {
const base = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "omb-upload-parent-")));
dirs.push(base);
const workspace = join(base, "workspace"), sub = join(workspace, "sub"), outside = join(base, "outside");
fs.mkdirSync(sub, { recursive: true });
fs.mkdirSync(outside);
fs.writeFileSync(join(sub, "report.txt"), "report");
fs.writeFileSync(join(outside, "report.txt"), "outside secret");
const checked = uploadableFiles([join(sub, "report.txt")], [workspace]);
if ("error" in checked) throw new Error(checked.error);
fs.rmSync(sub, { recursive: true });
fs.symlinkSync(outside, sub, "dir");
await expect(async () => namedUploadPaths(checked.files.map((file) => ({ file, name: "Report.txt" })), join(base, "uploads"))).rejects.toThrow();
});