security: workbook path allowlist + stronger image polyglot validation (#54)
- dispo workbook imports are pinned to DISPO_IMPORT_DIR (default ./imports): tRPC input rejects absolute paths and .. segments, runtime reader re-validates containment via path.relative. Closes a path-traversal class that reached ExcelJS CVEs through admin/compromised tokens. - image validator now checks the full 8-byte PNG magic, enforces PNG IEND and JPEG EOI trailers, scans the decoded buffer for markup polyglot markers (<script, <svg, <iframe, javascript:, onerror=, ...), and explicitly rejects SVG. Provider-generated covers (DALL-E, Gemini) run through the same validator before persistence — an untrusted upstream cannot smuggle a stored-XSS payload past us. - added image-validation.test.ts and tightened documentation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { cp, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_DISPO_WORKBOOK_BYTES,
|
||||
MAX_DISPO_WORKBOOK_COLUMNS,
|
||||
@@ -33,6 +33,20 @@ const itIfSamples = hasSamples ? it : it.skip;
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
// The dispo reader now enforces DISPO_IMPORT_DIR as an allowlist. Existing
|
||||
// tests pass absolute paths from sample fixtures or tmpdirs that live outside
|
||||
// any production import dir, so scope the allowlist to the filesystem root
|
||||
// for the test suite. New tests below restore a narrow allowlist to exercise
|
||||
// the containment check explicitly.
|
||||
const originalImportDir = process.env["DISPO_IMPORT_DIR"];
|
||||
beforeAll(() => {
|
||||
process.env["DISPO_IMPORT_DIR"] = "/";
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalImportDir === undefined) delete process.env["DISPO_IMPORT_DIR"];
|
||||
else process.env["DISPO_IMPORT_DIR"] = originalImportDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirectories.splice(0).map(async (directory) => {
|
||||
@@ -136,4 +150,58 @@ describe("readWorksheetMatrix", () => {
|
||||
`exceeds the ${MAX_DISPO_WORKBOOK_COLUMNS} column import limit`,
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
describe("DISPO_IMPORT_DIR allowlist", () => {
|
||||
it("rejects absolute paths that escape the configured import dir", async () => {
|
||||
const allowedDir = await makeTempDirectory();
|
||||
const outsideDir = await makeTempDirectory();
|
||||
const outsidePath = path.join(outsideDir, "outside.xlsx");
|
||||
await writeWorkbook(outsidePath, [["a"]]);
|
||||
|
||||
const previous = process.env["DISPO_IMPORT_DIR"];
|
||||
process.env["DISPO_IMPORT_DIR"] = allowedDir;
|
||||
try {
|
||||
await expect(readWorksheetMatrix(outsidePath, "Sheet1")).rejects.toThrow(
|
||||
"Workbook path must be inside the configured import directory",
|
||||
);
|
||||
} finally {
|
||||
process.env["DISPO_IMPORT_DIR"] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects relative paths that traverse out of the configured import dir", async () => {
|
||||
const allowedDir = await makeTempDirectory();
|
||||
const siblingDir = await makeTempDirectory();
|
||||
const siblingPath = path.join(siblingDir, "sibling.xlsx");
|
||||
await writeWorkbook(siblingPath, [["a"]]);
|
||||
|
||||
const relative = path.relative(allowedDir, siblingPath);
|
||||
expect(relative.startsWith("..")).toBe(true);
|
||||
|
||||
const previous = process.env["DISPO_IMPORT_DIR"];
|
||||
process.env["DISPO_IMPORT_DIR"] = allowedDir;
|
||||
try {
|
||||
await expect(readWorksheetMatrix(relative, "Sheet1")).rejects.toThrow(
|
||||
"Workbook path must be inside the configured import directory",
|
||||
);
|
||||
} finally {
|
||||
process.env["DISPO_IMPORT_DIR"] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts paths that resolve inside the configured import dir", async () => {
|
||||
const allowedDir = await makeTempDirectory();
|
||||
const insidePath = path.join(allowedDir, "inside.xlsx");
|
||||
await writeWorkbook(insidePath, [["hello"]]);
|
||||
|
||||
const previous = process.env["DISPO_IMPORT_DIR"];
|
||||
process.env["DISPO_IMPORT_DIR"] = allowedDir;
|
||||
try {
|
||||
const rows = await readWorksheetMatrix("inside.xlsx", "Sheet1");
|
||||
expect(rows[0]?.[0]).toBe("hello");
|
||||
} finally {
|
||||
process.env["DISPO_IMPORT_DIR"] = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user