mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 11:19:42 +02:00
fix(preview): bound what ExcelJS expands during XLSX admission
ExcelJS 4.4.0 expands three constructs into one object per cell or column at load time, so a few KB admitted as one cell could cost a gigabyte: - a <mergeCell> now costs its full area against the per-sheet and total cell caps, and a ref that does not parse is refused - a <col> whose min or max is past 16384 is refused - the worker loads with ignoreNodes: ['dataValidations']; the preview never shows validations, and a whole-column dropdown took 5 s The XML counter now scans up to the last complete tag and carries the rest, so a merge or col tag cut by an inflate-chunk edge is read whole. A central-directory compressedSize that runs past the file is refused, since the ratio cap divides by it. The renderer and core axis offsets use prefix sums with a binary search instead of walking every override per call.
This commit is contained in:
@@ -125,7 +125,10 @@ async function loadWorkbook(bytes) {
|
||||
const admitted = core.buildAdmittedArchive(admission, self.fflate);
|
||||
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
|
||||
const nextWorkbook = new self.ExcelJS.Workbook();
|
||||
await nextWorkbook.xlsx.load(admitted);
|
||||
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||
// object (a whole-column dropdown is a million), and the preview never shows
|
||||
// validations, so they are not parsed at all.
|
||||
await nextWorkbook.xlsx.load(admitted, { ignoreNodes: ['dataValidations'] });
|
||||
const nextSheets = new Map();
|
||||
const nextRows = new Map();
|
||||
normalizedStyles = [];
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '91b615278d5b';
|
||||
const SPREADSHEET_ASSET_VERSION = '078d54b1055a';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
@@ -82,14 +82,37 @@
|
||||
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
|
||||
}
|
||||
|
||||
// Prefix sums per override list, built once per sheet's axis: renderTile
|
||||
// asks for several offsets per cell, so a linear walk over every override
|
||||
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
|
||||
const axisDeltas = new WeakMap();
|
||||
|
||||
function overrideDeltas(overrides, defaultSize) {
|
||||
let entry = axisDeltas.get(overrides);
|
||||
if (!entry || entry.defaultSize !== defaultSize) {
|
||||
const deltas = [];
|
||||
let delta = 0;
|
||||
for (const [, size] of overrides) {
|
||||
delta += size - defaultSize;
|
||||
deltas.push(delta);
|
||||
}
|
||||
entry = { defaultSize, deltas };
|
||||
axisDeltas.set(overrides, entry);
|
||||
}
|
||||
return entry.deltas;
|
||||
}
|
||||
|
||||
function axisOffset(count, defaultSize, overrides, index) {
|
||||
const bounded = Math.max(1, Math.min(count + 1, index));
|
||||
let value = (bounded - 1) * defaultSize;
|
||||
for (const [overrideIndex, size] of overrides || []) {
|
||||
if (overrideIndex >= bounded) break;
|
||||
value += size - defaultSize;
|
||||
const list = overrides || [];
|
||||
let low = 0;
|
||||
let high = list.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (list[mid][0] < bounded) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return value;
|
||||
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
|
||||
}
|
||||
|
||||
function axisIndex(count, defaultSize, overrides, offset) {
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
const localExtraLength = u16(bytes, localHeaderOffset + 28);
|
||||
const localNameEnd = localHeaderOffset + 30 + localNameLength;
|
||||
if (localNameEnd + localExtraLength > directoryOffset) fail('malformed', 'Malformed XLSX local entry bounds');
|
||||
// The compression-ratio cap divides by this declared size, so it must
|
||||
// describe bytes that actually exist before the central directory.
|
||||
if (localNameEnd + localExtraLength + compressedSize > directoryOffset) {
|
||||
fail('malformed', 'XLSX entry declares more compressed bytes than the file holds');
|
||||
}
|
||||
const localName = new TextDecoder().decode(bytes.subarray(localHeaderOffset + 30, localNameEnd));
|
||||
if (localName !== name) fail('malformed', 'XLSX local and central directory names do not match');
|
||||
entries.push({ name, compressedSize, declaredSize, localHeaderOffset });
|
||||
@@ -115,6 +120,36 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// Longest single XML tag the streaming counter will carry across chunks. Real
|
||||
// worksheet tags are a few hundred bytes; this only stops a pathological tag
|
||||
// from turning the carried tail into quadratic re-scanning.
|
||||
const MAX_CARRIED_TAG = 256 * 1024;
|
||||
|
||||
function attribute(tag, name) {
|
||||
const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
||||
return match ? (match[1] ?? match[2]) : null;
|
||||
}
|
||||
|
||||
// ExcelJS expands a merge into one cell object per covered cell at load time,
|
||||
// so a merge costs its AREA, not one tag.
|
||||
function mergeArea(tag) {
|
||||
const range = parseRange(attribute(tag, 'ref'));
|
||||
if (!range) fail('malformed', 'Worksheet has a merged range that does not parse');
|
||||
return (Math.abs(range.r2 - range.r1) + 1) * (Math.abs(range.c2 - range.c1) + 1);
|
||||
}
|
||||
|
||||
// ExcelJS builds one column object for every index up to `<col max>`, unclamped.
|
||||
function checkColumnSpan(tag) {
|
||||
for (const name of ['min', 'max']) {
|
||||
const value = attribute(tag, name);
|
||||
if (value === null) continue;
|
||||
const index = Number(value);
|
||||
if (!Number.isInteger(index) || index < 1 || index > MAX_COL) {
|
||||
fail('malformed', `Worksheet column ${name} is outside 1-${MAX_COL}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createXmlCounter(name, counts, limits) {
|
||||
let tail = '';
|
||||
const decoder = new TextDecoder();
|
||||
@@ -123,22 +158,34 @@
|
||||
let inCellXfs = false;
|
||||
const worksheet = /^xl\/worksheets\/[^/]+\.xml$/i.test(name);
|
||||
const styles = name === 'xl/styles.xml';
|
||||
const addCells = (cells) => {
|
||||
sheetCells += cells;
|
||||
counts.cells += cells;
|
||||
if (sheetCells > limits.maxCellsPerSheet || counts.cells > limits.maxCells)
|
||||
fail('cell-limit', 'Workbook exceeds the cells limit');
|
||||
};
|
||||
return {
|
||||
push(chunk, final) {
|
||||
if (!worksheet && !styles) return;
|
||||
const text = tail + decoder.decode(chunk, { stream: !final });
|
||||
const safeEnd = final ? text.length : Math.max(0, text.length - 128);
|
||||
// Scan only up to a tag boundary: a tag cut by a chunk edge is carried
|
||||
// whole into the next scan, so its attributes are read in one piece.
|
||||
let safeEnd = text.length;
|
||||
if (!final) {
|
||||
const open = text.lastIndexOf('<');
|
||||
if (open > text.lastIndexOf('>')) safeEnd = open;
|
||||
}
|
||||
const scan = text.slice(0, safeEnd);
|
||||
if (worksheet) {
|
||||
const cells = (scan.match(/<c(?:\s|>)/g) || []).length;
|
||||
const merges = (scan.match(/<mergeCell(?:\s|>)/g) || []).length;
|
||||
sheetCells += cells;
|
||||
sheetMerges += merges;
|
||||
counts.cells += cells;
|
||||
counts.merges += merges;
|
||||
if (sheetCells > limits.maxCellsPerSheet || counts.cells > limits.maxCells)
|
||||
fail('cell-limit', 'Workbook exceeds the cells limit');
|
||||
if (sheetMerges > limits.maxMergesPerSheet) fail('merge-limit', 'Worksheet exceeds the merged ranges limit');
|
||||
addCells((scan.match(/<c(?:\s|>)/g) || []).length);
|
||||
for (const [tag] of scan.matchAll(/<mergeCell(?:\s[^>]*)?>/g)) {
|
||||
sheetMerges += 1;
|
||||
counts.merges += 1;
|
||||
if (sheetMerges > limits.maxMergesPerSheet)
|
||||
fail('merge-limit', 'Worksheet exceeds the merged ranges limit');
|
||||
addCells(mergeArea(tag));
|
||||
}
|
||||
for (const [tag] of scan.matchAll(/<col(?:\s[^>]*)?>/g)) checkColumnSpan(tag);
|
||||
}
|
||||
if (styles) {
|
||||
const tokens = scan.match(/<cellXfs(?:\s|>)|<\/cellXfs\s*>|<xf(?:\s|\/?>)/g) || [];
|
||||
@@ -150,6 +197,7 @@
|
||||
if (counts.styles > limits.maxStyles) fail('style-limit', 'Workbook exceeds the cell styles limit');
|
||||
}
|
||||
tail = text.slice(safeEnd);
|
||||
if (tail.length > MAX_CARRIED_TAG) fail('malformed', 'Workbook XML has an oversized tag');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -277,17 +325,34 @@
|
||||
.filter(([index, size]) => index >= 1 && index <= count && Number.isFinite(size))
|
||||
.map(([index, size]) => [index, Math.max(0, size)])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
return { count: Math.max(0, count), defaultSize: Math.max(0, defaultSize), overrides: sorted };
|
||||
const base = Math.max(0, defaultSize);
|
||||
// deltas[i] is the summed size change of overrides[0..i], so an offset is
|
||||
// one binary search instead of a walk over every override.
|
||||
const deltas = [];
|
||||
let delta = 0;
|
||||
for (const [, size] of sorted) {
|
||||
delta += size - base;
|
||||
deltas.push(delta);
|
||||
}
|
||||
return { count: Math.max(0, count), defaultSize: base, overrides: sorted, deltas };
|
||||
}
|
||||
|
||||
// Number of overrides whose index is below `bounded`.
|
||||
function overridesBefore(overrides, bounded) {
|
||||
let low = 0;
|
||||
let high = overrides.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (overrides[mid][0] < bounded) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function axisOffset(axis, index) {
|
||||
const bounded = Math.max(1, Math.min(axis.count + 1, index));
|
||||
let offset = (bounded - 1) * axis.defaultSize;
|
||||
for (const [overrideIndex, size] of axis.overrides) {
|
||||
if (overrideIndex >= bounded) break;
|
||||
offset += size - axis.defaultSize;
|
||||
}
|
||||
return offset;
|
||||
const before = overridesBefore(axis.overrides, bounded);
|
||||
return (bounded - 1) * axis.defaultSize + (before ? axis.deltas[before - 1] : 0);
|
||||
}
|
||||
|
||||
function axisIndexAt(axis, offset) {
|
||||
@@ -641,6 +706,7 @@
|
||||
MAX_COL,
|
||||
XlsxPreviewError,
|
||||
inspectZipDirectory,
|
||||
createXmlCounter,
|
||||
admitXlsx,
|
||||
buildAdmittedArchive,
|
||||
parseCellRef,
|
||||
|
||||
Reference in New Issue
Block a user