mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(preview): cap cell text, bound merges workbook-wide, refuse runaway number formats
This commit is contained in:
@@ -856,3 +856,133 @@ describe('spreadsheet preview worker: rows and cells are indexed by their presen
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
/** Deterministic, poorly compressible text, so the ratio cap does not refuse it first. */
|
||||
function noisyText(length: number, seed = 1): string {
|
||||
let state = seed;
|
||||
let text = '';
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
||||
text += String.fromCharCode(97 + (state % 26));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
describe('spreadsheet preview worker: cell text reaching the page is bounded', () => {
|
||||
// Every tile cell carried its whole string and structured clone copied it
|
||||
// once per cell: one 1 MB shared string over a 60 x 20 block froze the page.
|
||||
it('caps every tile cell text, shared string, rich text and hyperlink alike', async () => {
|
||||
const long = noisyText(200_000);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Long');
|
||||
for (let row = 1; row <= 20; row += 1) {
|
||||
for (let col = 1; col <= 10; col += 1) sheet.getCell(row, col).value = long;
|
||||
}
|
||||
sheet.getCell(21, 1).value = { richText: [{ text: long }, { font: { bold: true }, text: long }] };
|
||||
sheet.getCell(21, 2).value = { text: long, hyperlink: 'https://example.invalid/' };
|
||||
sheet.getCell(21, 3).value = 'short';
|
||||
const bytes = await writeWorkbook(workbook);
|
||||
const entries = fflate.unzipSync(new Uint8Array(bytes));
|
||||
// One shared string (index 0), referenced by every cell of the block.
|
||||
expect(fflate.strFromU8(entries['xl/sharedStrings.xml'])).toContain(`<si><t>${long}</t></si>`);
|
||||
expect(
|
||||
fflate.strFromU8(entries['xl/worksheets/sheet1.xml']).match(/t="s"><v>0<\/v>/g)?.length
|
||||
).toBeGreaterThanOrEqual(200);
|
||||
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, bytes);
|
||||
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 21, c2: 10 });
|
||||
expect(tile.type).toBe('tile');
|
||||
const cells = tile.cells as Array<{ row: number; col: number; text: string }>;
|
||||
expect(cells).toHaveLength(203);
|
||||
for (const cell of cells) expect(cell.text.length, `${cell.row}:${cell.col}`).toBeLessThanOrEqual(1000);
|
||||
expect(cells.find((cell) => cell.row === 1 && cell.col === 1)?.text).toBe(`${long.slice(0, 999)}…`);
|
||||
expect(cells.find((cell) => cell.row === 21 && cell.col === 1)?.text.length).toBe(1000);
|
||||
expect(cells.find((cell) => cell.row === 21 && cell.col === 2)?.text.length).toBe(1000);
|
||||
expect(cells.find((cell) => cell.row === 21 && cell.col === 3)?.text).toBe('short');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
/** A workbook of `sheets` one-cell sheets, each carrying `mergesPerSheet` one-row merges. */
|
||||
async function mergeHeavyWorkbook(sheets: number, mergesPerSheet: number): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
for (let i = 1; i <= sheets; i += 1) workbook.addWorksheet(`S${i}`).getCell('A1').value = 'one';
|
||||
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
||||
const merges = Array.from({ length: mergesPerSheet }, (_, i) => `<mergeCell ref="A${i + 2}:B${i + 2}"/>`).join('');
|
||||
for (let i = 1; i <= sheets; i += 1) {
|
||||
const name = `xl/worksheets/sheet${i}.xml`;
|
||||
const sheet = fflate.strFromU8(entries[name]);
|
||||
const patched = sheet.replace(
|
||||
'</sheetData>',
|
||||
`</sheetData><mergeCells count="${mergesPerSheet}">${merges}</mergeCells>`
|
||||
);
|
||||
expect(patched).not.toBe(sheet);
|
||||
entries[name] = fflate.strToU8(patched);
|
||||
}
|
||||
return toArrayBuffer(fflate.zipSync(entries));
|
||||
}
|
||||
|
||||
describe('spreadsheet preview worker: merges are bounded per sheet and workbook-wide', () => {
|
||||
// ExcelJS checks each new merge against every earlier one on its sheet, so
|
||||
// a sheet's load cost grows with the square of its merge count.
|
||||
it('refuses one sheet over the per-sheet merge cap before ExcelJS loads', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.send({ type: 'load', bytes: await mergeHeavyWorkbook(1, 2_001) });
|
||||
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'merge-limit' });
|
||||
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
it('refuses sheets each under the per-sheet cap once the workbook total passes 10,000', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.send({ type: 'load', bytes: await mergeHeavyWorkbook(6, 2_000) });
|
||||
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'merge-limit' });
|
||||
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
it('still previews a sheet at the per-sheet merge cap', async () => {
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, await mergeHeavyWorkbook(1, 2_000));
|
||||
expect(metadata.sheets[0].merges).toHaveLength(2_000);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
/** A one-cell numeric workbook whose styles.xml declares `formatCode` for the cell. */
|
||||
async function numberFormatWorkbook(formatCode: string): Promise<ArrayBuffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Data');
|
||||
sheet.getCell('A1').value = 1.5;
|
||||
sheet.getCell('A1').numFmt = '0.000';
|
||||
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
||||
const styles = fflate.strFromU8(entries['xl/styles.xml']);
|
||||
const patched = styles.replace('formatCode="0.000"', `formatCode="${formatCode}"`);
|
||||
expect(patched).not.toBe(styles);
|
||||
entries['xl/styles.xml'] = fflate.strToU8(patched);
|
||||
return toArrayBuffer(fflate.zipSync(entries));
|
||||
}
|
||||
|
||||
describe('spreadsheet preview worker: number formats ExcelJS would rescan', () => {
|
||||
// `isDateFmt` runs `/\[[^\]]*]/g` per numeric cell; a 60,000-character code of
|
||||
// `[` cost 2.2 s a cell, and the code is echoed into the notice bar.
|
||||
it.each([
|
||||
['an unclosed [', `0${'['.repeat(200)}`],
|
||||
['a code over 255 characters', `${'0'.repeat(300)}.00`],
|
||||
])(
|
||||
'refuses %s before ExcelJS loads',
|
||||
async (_label, code) => {
|
||||
const harness = createHarness();
|
||||
await harness.send({ type: 'load', bytes: await numberFormatWorkbook(code) });
|
||||
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'number-format' });
|
||||
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
||||
},
|
||||
60_000
|
||||
);
|
||||
|
||||
it('previews a closed bracketed format and keeps its warning bounded', async () => {
|
||||
const code = `[Red]${'0'.repeat(200)}`;
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, await numberFormatWorkbook(code));
|
||||
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 1, c2: 1 });
|
||||
expect(tile.cells).toEqual([expect.objectContaining({ row: 1, col: 1, text: '1.5' })]);
|
||||
expect(tile.warnings).toEqual([`Unsupported number format: ${code}`]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -333,6 +333,101 @@ describe('spreadsheet XLSX core', () => {
|
||||
expect(() => counter.push(fflate.strToU8(xml), true)).toThrowError(/styles limit/i);
|
||||
});
|
||||
|
||||
// ExcelJS's `_mergeCellsInternal` checks every new merge against every earlier
|
||||
// one on its sheet, so a sheet costs the SQUARE of its merge count: one sheet
|
||||
// at the old 5,000 cap took 1.6 s, and 20 such sheets ran past the page timeout.
|
||||
it('caps merges per sheet and across the workbook', () => {
|
||||
expect(core.LIMITS.maxMergesPerSheet).toBe(2000);
|
||||
expect(core.LIMITS.maxMerges).toBe(10000);
|
||||
const merges = (n: number, row = 1) =>
|
||||
Array.from({ length: n }, (_, i) => `<mergeCell ref="A${row + i * 2}:A${row + i * 2 + 1}"/>`).join('');
|
||||
const sheetXml = (n: number) => `<worksheet><sheetData/><mergeCells>${merges(n)}</mergeCells></worksheet>`;
|
||||
expect(() => core.admitXlsx(workbookZip(sheetXml(4)), fflate, { maxMergesPerSheet: 3 })).toThrowError(
|
||||
/merged ranges limit/i
|
||||
);
|
||||
expect(() => core.admitXlsx(workbookZip(sheetXml(3)), fflate, { maxMergesPerSheet: 3 })).not.toThrow();
|
||||
// Two sheets each under the per-sheet cap still trip the workbook-wide one.
|
||||
const twoSheets = fflate.zipSync({
|
||||
...fflate.unzipSync(workbookZip(sheetXml(3))),
|
||||
'xl/worksheets/sheet2.xml': fflate.strToU8(sheetXml(3)),
|
||||
});
|
||||
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||
/merged ranges limit/i
|
||||
);
|
||||
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 6 })).not.toThrow();
|
||||
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||
expect.objectContaining({ code: 'merge-limit' })
|
||||
);
|
||||
});
|
||||
|
||||
// ExcelJS runs `isDateFmt` once per numeric cell, and its first step,
|
||||
// `fmt.replace(/\[[^\]]*]/g, '')`, rescans to the end of the code for every
|
||||
// `[` with no later `]`. The code is also echoed into the notice bar.
|
||||
it('refuses a <numFmt> formatCode over 255 characters or with a [ after its last ]', () => {
|
||||
const styles = (numFmts: string) => {
|
||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||
const xml = `<styleSheet><numFmts count="1">${numFmts}</numFmts><cellXfs count="1"><xf/></cellXfs></styleSheet>`;
|
||||
return () => counter.push(fflate.strToU8(xml), true);
|
||||
};
|
||||
const fmt = (code: string) => `<numFmt numFmtId="164" formatCode="${code}"/>`;
|
||||
expect(styles(fmt('0'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||
expect(styles(fmt('0'.repeat(255)))).not.toThrow();
|
||||
for (const code of ['[', '0[', '[Red]0[', '[[[[', '[Red]0.00;[']) {
|
||||
expect(styles(fmt(code)), code).toThrowError(/unclosed bracket/i);
|
||||
}
|
||||
for (const code of ['[Red]0.00', '[$-409]mmm d, yyyy', '[h]:mm:ss', '0.00', '#,##0;[Red]-#,##0', ']', '[[]']) {
|
||||
expect(styles(fmt(code)), code).not.toThrow();
|
||||
}
|
||||
// The code is checked as ExcelJS decodes it: an entity cannot hide a `[`,
|
||||
// and an entity-heavy code is measured by its decoded length.
|
||||
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||
expect(styles(fmt('"x"'.repeat(60)))).not.toThrow();
|
||||
expect(styles(fmt('&'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||
// Attributes are read in order, so a quoted fake formatCode cannot mask the real one.
|
||||
expect(styles(`<numFmt x=' formatCode="0"' numFmtId="164" formatCode="0["/>`)).toThrowError(/unclosed bracket/i);
|
||||
expect(styles('<numFmt numFmtId="164" formatCode="0" junk/>')).toThrowError(/do not parse/i);
|
||||
// `<numFmts>` is the container, not a format.
|
||||
expect(styles('')).not.toThrow();
|
||||
// The refusal never echoes the code itself.
|
||||
for (const code of ['QZJX[', `${'QZJX'.repeat(64)}0`]) {
|
||||
expect(styles(fmt(code))).toThrowError(
|
||||
expect.objectContaining({ code: 'number-format', message: expect.not.stringContaining('QZJX') })
|
||||
);
|
||||
}
|
||||
// Every <numFmt> in the file is read, wherever it sits (dxfs carry them too).
|
||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||
const dxf = `<styleSheet><dxfs count="1"><dxf>${fmt('0[')}</dxf></dxfs></styleSheet>`;
|
||||
expect(() => counter.push(fflate.strToU8(dxf), true)).toThrowError(/unclosed bracket/i);
|
||||
});
|
||||
|
||||
it('caps cell display text at 1,000 characters for every value shape', () => {
|
||||
const long = 'x'.repeat(50_000);
|
||||
const shapes: Array<[string, unknown]> = [
|
||||
['string', long],
|
||||
['rich text', { richText: [{ text: long }, { text: long }] }],
|
||||
['hyperlink', { text: long, hyperlink: 'https://example.invalid/' }],
|
||||
['rich hyperlink', { text: { richText: [{ text: long }] }, hyperlink: 'https://example.invalid/' }],
|
||||
['formula source', { formula: long }],
|
||||
['formula result', { formula: 'A1', result: long }],
|
||||
['error', { error: long }],
|
||||
];
|
||||
for (const [label, value] of shapes) {
|
||||
const { text } = core.formatCellValue(value, 'General');
|
||||
expect(text.length, label).toBeLessThanOrEqual(1000);
|
||||
expect(text.endsWith('…'), label).toBe(true);
|
||||
}
|
||||
expect(core.formatCellValue('y'.repeat(1000), 'General').text).toBe('y'.repeat(1000));
|
||||
expect(core.formatCellValue({ richText: [{ text: 'a' }, { text: 'b' }] }, 'General').text).toBe('ab');
|
||||
// A cut never leaves half of a surrogate pair.
|
||||
// 'aa' puts a high surrogate at index 998, exactly where a naive cut lands.
|
||||
const emoji = core.formatCellValue('aa' + '😀'.repeat(600), 'General').text;
|
||||
expect(emoji.length).toBeLessThanOrEqual(1000);
|
||||
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an entry whose declared compressed size runs past the file', () => {
|
||||
const zip = workbookZip();
|
||||
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
||||
|
||||
Reference in New Issue
Block a user