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:
@@ -4,6 +4,18 @@ import path from "node:path";
|
||||
export type WorksheetCellValue = boolean | Date | number | string | null;
|
||||
export type WorksheetMatrix = WorksheetCellValue[][];
|
||||
|
||||
// Path allowlist: dispo workbooks must live inside DISPO_IMPORT_DIR. Without
|
||||
// this guard an admin (or a compromised admin token) could point the ExcelJS
|
||||
// parser at any file the app process can read, reaching library CVEs on
|
||||
// arbitrary filesystem paths. Default picks an in-repo `imports/` directory so
|
||||
// local dev still works; production deployments should set DISPO_IMPORT_DIR
|
||||
// explicitly to a dedicated volume.
|
||||
function resolveImportDir(): string {
|
||||
const configured = process.env["DISPO_IMPORT_DIR"];
|
||||
const base = configured && configured.trim().length > 0 ? configured : path.resolve("imports");
|
||||
return path.resolve(base);
|
||||
}
|
||||
|
||||
type ExcelJsModule = typeof import("exceljs");
|
||||
type ExcelJsWorkbook = InstanceType<ExcelJsModule["Workbook"]>;
|
||||
type ExcelJsXlsxReader = ExcelJsWorkbook["xlsx"] & {
|
||||
@@ -25,7 +37,9 @@ const EXCELJS_UNSUPPORTED_TABLE_FILTER_MARKER = '"name":"dateGroupItem"';
|
||||
let _excelJs: ExcelJsModule | null = null;
|
||||
const worksheetMatrixCache = new Map<string, Promise<WorksheetMatrix>>();
|
||||
|
||||
function normalizeExcelJsModule(module: ExcelJsModule | { default?: ExcelJsModule }): ExcelJsModule {
|
||||
function normalizeExcelJsModule(
|
||||
module: ExcelJsModule | { default?: ExcelJsModule },
|
||||
): ExcelJsModule {
|
||||
return "Workbook" in module ? module : (module.default as ExcelJsModule);
|
||||
}
|
||||
|
||||
@@ -58,7 +72,19 @@ function cloneWorksheetMatrix(rows: WorksheetMatrix): WorksheetMatrix {
|
||||
}
|
||||
|
||||
async function validateWorkbookPath(workbookPath: string): Promise<string> {
|
||||
const resolvedPath = path.resolve(workbookPath);
|
||||
const importDir = resolveImportDir();
|
||||
const resolvedPath = path.resolve(importDir, workbookPath);
|
||||
|
||||
// path.relative returns a string that either starts with ".." (or equals
|
||||
// "..") or is absolute when the resolved path escapes importDir. Both are
|
||||
// rejected — defence against `..` sequences, symlink-shaped escapes and
|
||||
// absolute-path injection via the tRPC surface.
|
||||
const relative = path.relative(importDir, resolvedPath);
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(
|
||||
`Workbook path must be inside the configured import directory: "${workbookPath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (path.extname(resolvedPath).toLowerCase() !== DISPO_WORKBOOK_EXTENSION) {
|
||||
throw new Error(
|
||||
@@ -132,7 +158,11 @@ function normalizeWorksheetCellValue(value: unknown): WorksheetCellValue {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function assertWorksheetShape(rows: WorksheetMatrix, sheetName: string, workbookPath: string): void {
|
||||
function assertWorksheetShape(
|
||||
rows: WorksheetMatrix,
|
||||
sheetName: string,
|
||||
workbookPath: string,
|
||||
): void {
|
||||
if (rows.length > MAX_DISPO_WORKBOOK_ROWS) {
|
||||
throw new Error(
|
||||
`Worksheet "${sheetName}" in "${workbookPath}" exceeds the ${MAX_DISPO_WORKBOOK_ROWS} row import limit.`,
|
||||
|
||||
Reference in New Issue
Block a user