safesheet
v1.1.0
Published
A secure, streaming-first .xlsx reader/writer for Node.js — a drop-in-friendly replacement for xlsx (SheetJS) with no unpatched CVEs and no whole-file-in-memory footgun.
Maintainers
Readme
safesheet
A secure, streaming-first .xlsx reader/writer for Node.js — built as a drop-in-friendly replacement for xlsx (SheetJS).
Why not just use xlsx (SheetJS)?
The free xlsx npm package has two unpatched high-severity vulnerabilities (ReDoS and prototype pollution) with no fix published to npm — the maintainer moved fixes behind a paid registry instead. On top of that, it loads the entire workbook into memory as one big object graph: open a sheet with a few hundred thousand rows and it can crash the process with no useful error, and round-tripping a file (open, resave) silently drops formatting and custom properties.
safesheet fixes the underlying causes, not just the symptoms:
- Streams rows instead of building one big object. Reading never materializes the whole sheet — each row is handed to you as soon as it's parsed, so a 10-million-row sheet costs you one row's worth of memory, not ten million.
- A decompression-bomb guard, on by default.
.xlsxfiles are zip archives; a hostile file can be a few KB on disk and expand to gigabytes. Every entry safesheet decompresses is capped (maxDecompressedSheetBytes,maxSharedStringsBytes), and it aborts cleanly instead of exhausting memory. - No XML entity expansion. The XML parser (
sax) only resolves the five predefined XML entities and numeric character references — never DOCTYPE-defined entities — which rules out "billion laughs"-style attacks by construction, not by a denylist. - Prototype-pollution-safe by construction.
rowToObject()returns a null-prototype object, so a header column literally named__proto__orconstructorcan't be used to pollute anything — there's no prototype chain to write onto. - Real backpressure. Both reading and writing are chunked so a slow consumer (e.g. writing rows to a database) pauses the producer instead of it buffering unboundedly ahead.
- TypeScript-first. Full type definitions ship in the package.
Install
npm install safesheetQuick start
Reading
import { Workbook, isSafeSheetError } from "safesheet";
import { readFile } from "node:fs/promises";
const buffer = await readFile("report.xlsx");
const wb = Workbook.open(buffer);
console.log(wb.sheetNames); // ["Sheet1", "Sheet2", ...]
// Recommended: stream rows, never hold the whole sheet in memory
for await (const row of wb.readRows("Sheet1")) {
console.log(row); // [cellA, cellB, cellC, ...]
}
// Only for sheets you know are small
const rows = await wb.readSheetToArray("Sheet1");Map rows onto header names without the prototype-pollution risk of a plain object:
import { rowToObject } from "safesheet";
const rows = wb.readRows("Sheet1");
const { value: headerRow } = await rows.next();
for await (const row of rows) {
const record = rowToObject(headerRow as string[], row);
console.log(record.name, record.email);
}Writing
import { WorkbookWriter } from "safesheet";
import { writeFile } from "node:fs/promises";
const writer = new WorkbookWriter();
const sheet = writer.addSheet("Report");
sheet.writeRow(["name", "age"]);
sheet.writeRow(["Ada", 36]);
const buffer = await writer.finalize();
await writeFile("report.xlsx", buffer);
// Or stream compressed bytes straight to disk without buffering the
// whole workbook in memory:
import { createWriteStream } from "node:fs";
await writer.finalizeTo(createWriteStream("report.xlsx"));API
Workbook.open(buffer, options?)
Parses workbook metadata (sheet names) synchronously; opens instantly regardless of sheet size since row data isn't touched until you read it.
.sheetNames: string[].readRows(nameOrIndex, options?): AsyncGenerator<CellValue[]>— the recommended way to read anything that might be large.readSheetToArray(nameOrIndex, options?): Promise<CellValue[][]>— convenience for small sheets only
options (all optional, all have finite defaults — there's no "unlimited" mode):
| Option | Default |
| ---------------------------- | -------- |
| maxDecompressedSheetBytes | 512 MiB |
| maxSharedStringsBytes | 64 MiB |
| maxRows | 5,000,000 |
| maxCellBytes | 1 MiB |
| highWaterMark | 64 rows |
WorkbookWriter
.addSheet(name): SheetWriter—SheetWriter.writeRow(values: CellValue[]).finalize(): Promise<Buffer>.finalizeTo(writable): Promise<void>— streams compressed output directly to a NodeWritable
Errors
Every rejection is a SafeSheetError with a stable code: INVALID_WORKBOOK, SHEET_NOT_FOUND, LIMIT_DECOMPRESSED_SIZE, LIMIT_ENTRY_SIZE, LIMIT_ROW_COUNT, LIMIT_CELL_SIZE, XML_PARSE_ERROR. Use isSafeSheetError(err) to narrow the type.
What's out of scope for v1.1.0
- Formulas are read as their cached value only (the formula text itself isn't exposed)
- Cell styling/formatting is not read or written
- Writing always uses inline strings rather than a shared-string table (simpler, fully valid, slightly less space-efficient for highly repetitive text)
License
MIT
