835 lines
33 KiB
TypeScript
835 lines
33 KiB
TypeScript
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { tmpdir } from 'os';
|
||
import { afterEach, describe, expect, it } from 'vitest';
|
||
import AdmZip from 'adm-zip';
|
||
import ExcelJS from 'exceljs';
|
||
import { executeTool } from './office.js';
|
||
import type { ToolContext, ToolsConfig } from './core.js';
|
||
import { hideEntries, lieAboutUncompressedSize, rewriteAsZip64 } from '../../office/__fixtures__/forge-zip.js';
|
||
|
||
// --- Helpers (mirrors office.test.ts house style) ---
|
||
|
||
function makeWorkspace(): string {
|
||
return fs.mkdtempSync(path.join(tmpdir(), 'maestro-office-size-'));
|
||
}
|
||
|
||
function makeContext(workspacePath: string, toolsConfig?: ToolsConfig): ToolContext {
|
||
return { workspacePath, editAllowed: true, ...(toolsConfig ? { toolsConfig } : {}) };
|
||
}
|
||
|
||
function inputPath(workspacePath: string, name: string): string {
|
||
const dir = path.join(workspacePath, 'input');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
return path.join(dir, name);
|
||
}
|
||
|
||
/**
|
||
* Write a fixture that PASSES validateFileFormat (correct magic bytes for the
|
||
* expected format) but exceeds `sizeBytes`. The size-limit check in office.ts
|
||
* runs AFTER the magic-byte format check but BEFORE any real parse, so the file
|
||
* does not need to be a valid Office document — only the leading magic bytes +
|
||
* the right extension matter, plus total size > the configured cap.
|
||
*
|
||
* OOXML magic = "PK\x03\x04" (ReadExcel/ReadDocx/ReadPPTX). PDF magic = "%PDF-".
|
||
*/
|
||
function writeOversizedOoxml(filePath: string, sizeBytes: number): void {
|
||
const buf = Buffer.alloc(sizeBytes, 0);
|
||
// Local file header signature for a ZIP / OOXML container.
|
||
buf[0] = 0x50; // P
|
||
buf[1] = 0x4b; // K
|
||
buf[2] = 0x03;
|
||
buf[3] = 0x04;
|
||
fs.writeFileSync(filePath, buf);
|
||
}
|
||
|
||
function writeOversizedPdf(filePath: string, sizeBytes: number): void {
|
||
const buf = Buffer.alloc(sizeBytes, 0x20); // pad with spaces
|
||
Buffer.from('%PDF-1.4\n').copy(buf, 0);
|
||
fs.writeFileSync(filePath, buf);
|
||
}
|
||
|
||
/**
|
||
* Build a REAL .pptx (zip) whose total UNCOMPRESSED size exceeds the configured
|
||
* uncompressed cap, while staying tiny on disk (highly compressible zeros) — i.e.
|
||
* a decompression-bomb shape. adm-zip records the true uncompressed size in each
|
||
* entry's header.size, which is exactly what office.ts sums for ZIP-bomb detection.
|
||
*/
|
||
function writeZipBomb(filePath: string, uncompressedBytes: number): void {
|
||
const zip = new AdmZip();
|
||
// A single fat, highly-compressible entry: large uncompressed, tiny on disk.
|
||
zip.addFile('ppt/slides/slide1.xml', Buffer.alloc(uncompressedBytes, 0));
|
||
zip.writeZip(filePath);
|
||
}
|
||
|
||
const MB = 1024 * 1024;
|
||
|
||
/** そのままなら編集も読み取りも成功する、本物のブック(ExcelJS が書き出す形)。 */
|
||
async function writeWorkbook(filePath: string): Promise<void> {
|
||
const wb = new ExcelJS.Workbook();
|
||
const ws = wb.addWorksheet('Sheet1');
|
||
ws.getCell('B1').value = 10;
|
||
await wb.xlsx.writeFile(filePath);
|
||
}
|
||
|
||
const readCell = async (filePath: string, cell: string): Promise<unknown> => {
|
||
const wb = new ExcelJS.Workbook();
|
||
await wb.xlsx.readFile(filePath);
|
||
return wb.getWorksheet('Sheet1')!.getCell(cell).value;
|
||
};
|
||
|
||
function writeDocx(filePath: string): void {
|
||
const zip = new AdmZip();
|
||
zip.addFile('[Content_Types].xml', Buffer.from('<Types/>', 'utf-8'));
|
||
zip.addFile('word/document.xml', Buffer.from(
|
||
'<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>もとの本文</w:t></w:r></w:p></w:body></w:document>',
|
||
'utf-8'));
|
||
zip.writeZip(filePath);
|
||
}
|
||
|
||
function writePptx(filePath: string): void {
|
||
const zip = new AdmZip();
|
||
zip.addFile('[Content_Types].xml', Buffer.from('<Types/>', 'utf-8'));
|
||
zip.addFile('ppt/presentation.xml', Buffer.from(
|
||
'<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>',
|
||
'utf-8'));
|
||
zip.addFile('ppt/_rels/presentation.xml.rels', Buffer.from(
|
||
'<Relationships><Relationship Id="rId1" Type="slide" Target="slides/slide1.xml"/></Relationships>',
|
||
'utf-8'));
|
||
zip.addFile('ppt/slides/slide1.xml', Buffer.from(
|
||
'<p:sld xmlns:p="p" xmlns:a="a"><p:cSld><p:spTree><p:sp><p:txBody>'
|
||
+ '<a:p><a:r><a:rPr lang="ja-JP"/><a:t>もとの見出し</a:t></a:r></a:p>'
|
||
+ '</p:txBody></p:sp></p:spTree></p:cSld></p:sld>', 'utf-8'));
|
||
zip.writeZip(filePath);
|
||
}
|
||
|
||
describe('office tools — file size limits', () => {
|
||
let workspacePath = '';
|
||
|
||
afterEach(() => {
|
||
if (workspacePath) {
|
||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||
workspacePath = '';
|
||
}
|
||
});
|
||
|
||
// --- 1. ReadPPTX file-size cap (officePptxMaxSizeMb) ---
|
||
|
||
it('ReadPPTX rejects a file exceeding officePptxMaxSizeMb', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'big.pptx');
|
||
writeOversizedOoxml(fp, 2 * MB); // 2MB > 1MB cap
|
||
const ctx = makeContext(workspacePath, { officePptxMaxSizeMb: 1 });
|
||
|
||
const result = await executeTool('ReadPPTX', { path: 'input/big.pptx' }, ctx);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||
expect(result?.output).toContain('File size');
|
||
// Distinct from the format-mismatch error family (Japanese guidance text).
|
||
expect(result?.output).not.toContain('ReadToolDoc');
|
||
});
|
||
|
||
// --- 2. ReadPPTX ZIP-bomb path (officePptxMaxUncompressedMb) ---
|
||
|
||
it('ReadPPTX rejects a ZIP bomb whose uncompressed size exceeds officePptxMaxUncompressedMb', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'bomb.pptx');
|
||
// ~3MB uncompressed of zeros -> a few KB on disk (well under the 50MB file cap),
|
||
// so it passes the file-size check and reaches the uncompressed-size guard.
|
||
writeZipBomb(fp, 3 * MB);
|
||
const onDisk = fs.statSync(fp).size;
|
||
expect(onDisk).toBeLessThan(50 * MB); // sanity: stays under default file cap
|
||
|
||
const ctx = makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 });
|
||
const result = await executeTool('ReadPPTX', { path: 'input/bomb.pptx' }, ctx);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||
});
|
||
|
||
it('ReadPPTX accepts a small zip when uncompressed size is under the cap', async () => {
|
||
// Guards against the bomb check being over-eager / regressed to always-fire.
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'tiny.pptx');
|
||
writeZipBomb(fp, 4 * 1024); // 4KB uncompressed, well under 1MB cap
|
||
|
||
const ctx = makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 });
|
||
const result = await executeTool('ReadPPTX', { path: 'input/tiny.pptx' }, ctx);
|
||
|
||
// It will fail later for other reasons (no presentation.xml / no slides parsed),
|
||
// but it must NOT be rejected by the ZIP-bomb guard.
|
||
expect(result?.output ?? '').not.toContain('ZIP bomb detected');
|
||
});
|
||
|
||
// --- 3. ReadExcel / ReadDocx / ReadPdf each reject when size > their cap ---
|
||
|
||
it('ReadExcel rejects a file exceeding officeExcelMaxSizeMb', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'big.xlsx');
|
||
writeOversizedOoxml(fp, 2 * MB);
|
||
const ctx = makeContext(workspacePath, { officeExcelMaxSizeMb: 1 });
|
||
|
||
const result = await executeTool('ReadExcel', { path: 'input/big.xlsx' }, ctx);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('File size');
|
||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||
// Distinct from the format-mismatch error (which names other tools / Read).
|
||
expect(result?.output).not.toContain('ReadDocx');
|
||
});
|
||
|
||
it('ReadDocx rejects a file exceeding officeDocxMaxSizeMb', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'big.docx');
|
||
writeOversizedOoxml(fp, 2 * MB);
|
||
const ctx = makeContext(workspacePath, { officeDocxMaxSizeMb: 1 });
|
||
|
||
const result = await executeTool('ReadDocx', { path: 'input/big.docx' }, ctx);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('File size');
|
||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||
});
|
||
|
||
it('ReadPdf rejects a file exceeding officePdfMaxSizeMb', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'big.pdf');
|
||
writeOversizedPdf(fp, 2 * MB);
|
||
const ctx = makeContext(workspacePath, { officePdfMaxSizeMb: 1 });
|
||
|
||
const result = await executeTool('ReadPdf', { path: 'input/big.pdf' }, ctx);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('File size');
|
||
expect(result?.output).toContain('exceeds limit of 1MB');
|
||
});
|
||
|
||
// The size-limit error is structurally DISTINCT from the format-mismatch error.
|
||
it('size-limit error differs from the format-mismatch error', async () => {
|
||
workspacePath = makeWorkspace();
|
||
|
||
// (a) size-limit: valid OOXML magic + oversize -> FILE_TOO_LARGE message.
|
||
const bigFp = inputPath(workspacePath, 'big.xlsx');
|
||
writeOversizedOoxml(bigFp, 2 * MB);
|
||
const sizeRes = await executeTool(
|
||
'ReadExcel',
|
||
{ path: 'input/big.xlsx' },
|
||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||
);
|
||
|
||
// (b) format-mismatch: PDF bytes handed to ReadExcel -> guidance message.
|
||
const pdfFp = inputPath(workspacePath, 'wrong.xlsx');
|
||
writeOversizedPdf(pdfFp, 4 * 1024); // tiny so size cap is irrelevant
|
||
const fmtRes = await executeTool(
|
||
'ReadExcel',
|
||
{ path: 'input/wrong.xlsx' },
|
||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||
);
|
||
|
||
expect(sizeRes?.isError).toBe(true);
|
||
expect(fmtRes?.isError).toBe(true);
|
||
expect(sizeRes?.output).toContain('exceeds limit of');
|
||
expect(fmtRes?.output).not.toContain('exceeds limit of');
|
||
expect(sizeRes?.output).not.toBe(fmtRes?.output);
|
||
});
|
||
|
||
// --- 4. Config override changes the threshold ---
|
||
|
||
it('config override: a file that passes at default fails at a lowered officeExcelMaxSizeMb', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'mid.xlsx');
|
||
// 2MB: under the 10MB default Excel cap, over a lowered 1MB cap.
|
||
writeOversizedOoxml(fp, 2 * MB);
|
||
|
||
// Default cap (no override) -> NOT rejected for size (it fails later on parse,
|
||
// but never with the FILE_TOO_LARGE message).
|
||
const defaultRes = await executeTool(
|
||
'ReadExcel',
|
||
{ path: 'input/mid.xlsx' },
|
||
makeContext(workspacePath),
|
||
);
|
||
expect(defaultRes?.output ?? '').not.toContain('exceeds limit of');
|
||
|
||
// Lowered cap -> rejected for size.
|
||
const loweredRes = await executeTool(
|
||
'ReadExcel',
|
||
{ path: 'input/mid.xlsx' },
|
||
makeContext(workspacePath, { officeExcelMaxSizeMb: 1 }),
|
||
);
|
||
expect(loweredRes?.isError).toBe(true);
|
||
expect(loweredRes?.output).toContain('exceeds limit of 1MB');
|
||
});
|
||
|
||
it('config override: raising officePptxMaxUncompressedMb lets a previously-bombed zip through the guard', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'fat.pptx');
|
||
writeZipBomb(fp, 3 * MB); // 3MB uncompressed
|
||
|
||
// Low cap (1MB) -> tripped by the bomb guard.
|
||
const lowRes = await executeTool(
|
||
'ReadPPTX',
|
||
{ path: 'input/fat.pptx' },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||
);
|
||
expect(lowRes?.output).toContain('ZIP bomb detected');
|
||
|
||
// Raised cap (10MB) -> NOT tripped by the bomb guard (fails later on missing slides).
|
||
const highRes = await executeTool(
|
||
'ReadPPTX',
|
||
{ path: 'input/fat.pptx' },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
|
||
);
|
||
expect(highRes?.output ?? '').not.toContain('ZIP bomb detected');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 書き換える口(EditExcel / EditDocx / EditPptx)の展開後サイズの関所(#1067)。
|
||
*
|
||
* 素材は **そのままなら編集が成功する本物のファイル**に、開くと膨らむ項目を
|
||
* 1 つ足したもの。関所を外すと編集が通ってファイルが変わるので、上限を戻した
|
||
* ときに必ず落ちる。
|
||
*/
|
||
describe('office edit tools — uncompressed size limits', () => {
|
||
let workspacePath = '';
|
||
|
||
afterEach(() => {
|
||
if (workspacePath) {
|
||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||
workspacePath = '';
|
||
}
|
||
});
|
||
|
||
function outputPath(name: string): string {
|
||
const dir = path.join(workspacePath, 'output');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
return path.join(dir, name);
|
||
}
|
||
|
||
/** 開くと膨らむ項目を 1 つ足す(圧縮すると数 KB のゼロ埋め)。 */
|
||
function inflate(filePath: string, uncompressedBytes: number): void {
|
||
const zip = new AdmZip(filePath);
|
||
zip.addFile('docProps/thumbnail.bin', Buffer.alloc(uncompressedBytes, 0));
|
||
zip.writeZip(filePath);
|
||
}
|
||
|
||
it('EditExcel は展開後サイズが上限を超えるブックを、書き換えずに断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('fat.xlsx');
|
||
await writeWorkbook(fp);
|
||
inflate(fp, 3 * MB);
|
||
expect(fs.statSync(fp).size).toBeLessThan(MB); // ファイルの大きさでは弾けない
|
||
|
||
const result = await executeTool(
|
||
'EditExcel',
|
||
{ path: 'output/fat.xlsx', edits: [{ cell: 'B1', value: 42 }] },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
expect(await readCell(fp, 'B1')).toBe(10); // 中身は変わっていない
|
||
});
|
||
|
||
it('EditExcel は上限に収まるブックなら、そのまま書き換えられる', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('ok.xlsx');
|
||
await writeWorkbook(fp);
|
||
inflate(fp, 3 * MB);
|
||
|
||
const result = await executeTool(
|
||
'EditExcel',
|
||
{ path: 'output/ok.xlsx', edits: [{ cell: 'B1', value: 42 }] },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
|
||
);
|
||
|
||
expect(result?.isError).toBeFalsy();
|
||
expect(await readCell(fp, 'B1')).toBe(42);
|
||
});
|
||
|
||
it('EditDocx は展開後サイズが上限を超える文書を、書き換えずに断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('fat.docx');
|
||
writeDocx(fp);
|
||
inflate(fp, 3 * MB);
|
||
const before = fs.readFileSync(fp);
|
||
|
||
const result = await executeTool(
|
||
'EditDocx',
|
||
{ path: 'output/fat.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
expect(fs.readFileSync(fp).equals(before)).toBe(true);
|
||
});
|
||
|
||
it('EditDocx は上限に収まる文書なら、そのまま書き換えられる', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('ok.docx');
|
||
writeDocx(fp);
|
||
inflate(fp, 3 * MB);
|
||
|
||
const result = await executeTool(
|
||
'EditDocx',
|
||
{ path: 'output/ok.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
|
||
);
|
||
|
||
expect(result?.isError).toBeFalsy();
|
||
expect(new AdmZip(fp).readAsText('word/document.xml')).toContain('書き換えた');
|
||
});
|
||
|
||
it('EditPptx は展開後サイズが上限を超えるスライドを、書き換えずに断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('fat.pptx');
|
||
writePptx(fp);
|
||
inflate(fp, 3 * MB);
|
||
const before = fs.readFileSync(fp);
|
||
|
||
const result = await executeTool(
|
||
'EditPptx',
|
||
{ path: 'output/fat.pptx', edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
expect(fs.readFileSync(fp).equals(before)).toBe(true);
|
||
});
|
||
|
||
it('EditPptx は上限に収まるスライドなら、そのまま書き換えられる', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('ok.pptx');
|
||
writePptx(fp);
|
||
inflate(fp, 3 * MB);
|
||
|
||
const result = await executeTool(
|
||
'EditPptx',
|
||
{ path: 'output/ok.pptx', edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 10 }),
|
||
);
|
||
|
||
expect(result?.isError).toBeFalsy();
|
||
expect(new AdmZip(fp).readAsText('ppt/slides/slide1.xml')).toContain('書き換えた');
|
||
});
|
||
|
||
/**
|
||
* 申告する展開後サイズだけを書き換える(中身は小さいまま)。既定の上限が
|
||
* 効いていることを、200MB のファイルを実際に作らずに確かめるため。
|
||
* 中央ディレクトリを正しく辿るので、圧縮データの中の偶然の一致は踏まない。
|
||
*/
|
||
function declareUncompressedSize(filePath: string, bytes: number): void {
|
||
const buf = fs.readFileSync(filePath);
|
||
const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
||
const count = buf.readUInt16LE(eocd + 10);
|
||
let at = buf.readUInt32LE(eocd + 16);
|
||
for (let i = 0; i < count; i += 1) {
|
||
buf.writeUInt32LE(bytes, at + 24); // 中央ディレクトリ側
|
||
buf.writeUInt32LE(bytes, buf.readUInt32LE(at + 42) + 22); // ローカルヘッダ側
|
||
at += 46 + buf.readUInt16LE(at + 28) + buf.readUInt16LE(at + 30) + buf.readUInt16LE(at + 32);
|
||
}
|
||
fs.writeFileSync(filePath, buf);
|
||
}
|
||
|
||
/**
|
||
* 設定していない環境(本番の既定)で関所が効くことを見る。上限を実質無制限に
|
||
* 書き換えると、このテストだけが落ちる(#1067 レビュー P2-2)。
|
||
*/
|
||
it('設定が無くても、既定の展開後上限(200MB)で断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('default.docx');
|
||
writeDocx(fp);
|
||
declareUncompressedSize(fp, 250 * MB);
|
||
const before = fs.readFileSync(fp);
|
||
|
||
const result = await executeTool(
|
||
'EditDocx',
|
||
{ path: 'output/default.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
|
||
makeContext(workspacePath), // 設定なし=本番の既定
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
expect(result?.output).toContain('exceeds limit of 200MB');
|
||
expect(fs.readFileSync(fp).equals(before)).toBe(true);
|
||
});
|
||
|
||
/**
|
||
* 中身が空の項目を大量に並べた包みは、申告の合計が 0 バイトなのでサイズの
|
||
* 上限をいくつにしても素通りする。項目数の関所で断る(#1067 レビュー P2-1)。
|
||
*/
|
||
it('中身が空の項目を大量に並べた包みは、展開後サイズが 0 でも断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('many.docx');
|
||
writeDocx(fp);
|
||
const zip = new AdmZip(fp);
|
||
for (let i = 0; i < 10_001; i += 1) zip.addFile(`docProps/pad${i}.bin`, Buffer.alloc(0));
|
||
zip.writeZip(fp);
|
||
const before = fs.readFileSync(fp);
|
||
|
||
const result = await executeTool(
|
||
'EditDocx',
|
||
{ path: 'output/many.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
|
||
// 展開後サイズの上限は緩めておく。ここで効くのは項目数のほう。
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 200 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('entries exceeds limit of 10000');
|
||
expect(fs.readFileSync(fp).equals(before)).toBe(true);
|
||
});
|
||
|
||
/**
|
||
* 項目数の上限は設定で動かせる(#1067 レビュー P2-2 / P3-3)。
|
||
*
|
||
* 読むだけの口にも同じ関所が乗ったので、当たった利用者に回避手段が要る。
|
||
* 下げれば普通のファイルでも断り、上げれば通ることを両方向で見る。
|
||
*/
|
||
it('項目数の上限は officeMaxEntries で下げられる(書き換える口)', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('small.docx');
|
||
writeDocx(fp); // 項目は 2 つ
|
||
const before = fs.readFileSync(fp);
|
||
|
||
const result = await executeTool(
|
||
'EditDocx',
|
||
{ path: 'output/small.docx', edits: [{ paragraph: 0, text: '書き換えた' }] },
|
||
makeContext(workspacePath, { officeMaxEntries: 1 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('entries exceeds limit of 1');
|
||
expect(fs.readFileSync(fp).equals(before)).toBe(true);
|
||
});
|
||
|
||
it('項目数の上限は officeMaxEntries で上げられる(読むだけの口)', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = inputPath(workspacePath, 'small.pptx');
|
||
writePptx(fp); // 項目は 4 つ
|
||
|
||
const lowered = await executeTool(
|
||
'ReadPPTX',
|
||
{ path: 'input/small.pptx' },
|
||
makeContext(workspacePath, { officeMaxEntries: 2 }),
|
||
);
|
||
expect(lowered?.isError).toBe(true);
|
||
expect(lowered?.output).toContain('entries exceeds limit of 2');
|
||
|
||
const raised = await executeTool(
|
||
'ReadPPTX',
|
||
{ path: 'input/small.pptx' },
|
||
makeContext(workspacePath, { officeMaxEntries: 50 }),
|
||
);
|
||
expect(raised?.output ?? '').not.toContain('ZIP bomb detected');
|
||
});
|
||
|
||
// 一覧を返すだけの呼び出しでも、包みは開く。同じ関所を通す。
|
||
it('EditPptx のスライド一覧も、展開後サイズが上限を超えれば断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('list.pptx');
|
||
writePptx(fp);
|
||
inflate(fp, 3 * MB);
|
||
|
||
const result = await executeTool(
|
||
'EditPptx',
|
||
{ path: 'output/list.pptx', list_paragraphs: true },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
});
|
||
|
||
it('EditDocx の段落一覧も、展開後サイズが上限を超えれば断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const fp = outputPath('list.docx');
|
||
writeDocx(fp);
|
||
inflate(fp, 3 * MB);
|
||
|
||
const result = await executeTool(
|
||
'EditDocx',
|
||
{ path: 'output/list.docx', list_paragraphs: true },
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 }),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 申告を偽った包みを、関所が乗っている入口すべてで断る(#1084 段階 1)。
|
||
*
|
||
* 経路は表で持つ。**入口を足したときに追加漏れが起きにくい形**にしてある
|
||
* (段階 2 で関所が乗る 5 経路も、同じ表に並べる)。
|
||
*
|
||
* 素材はその場で組む。正当なファイル(この製品が自分で書き出したもの)に項目を
|
||
* 足したうえで、末尾の記録の件数欄を元に戻す。関所からは足した項目が見えず、
|
||
* 読み込み層からは見える——その食い違いが穴の本体。
|
||
*/
|
||
describe('office tools — 申告を偽った包みを断る', () => {
|
||
let workspacePath = '';
|
||
|
||
afterEach(() => {
|
||
if (workspacePath) {
|
||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||
workspacePath = '';
|
||
}
|
||
});
|
||
|
||
interface Route {
|
||
name: string;
|
||
file: string;
|
||
build: (filePath: string) => Promise<void> | void;
|
||
input: (relative: string) => Record<string, unknown>;
|
||
}
|
||
|
||
const ROUTES: Route[] = [
|
||
{
|
||
name: 'ReadPPTX',
|
||
file: 'input/deck.pptx',
|
||
build: writePptx,
|
||
input: (relative) => ({ path: relative }),
|
||
},
|
||
{
|
||
name: 'EditExcel',
|
||
file: 'output/book.xlsx',
|
||
build: writeWorkbook,
|
||
input: (relative) => ({ path: relative, edits: [{ cell: 'B1', value: 42 }] }),
|
||
},
|
||
{
|
||
name: 'EditDocx',
|
||
file: 'output/report.docx',
|
||
build: writeDocx,
|
||
input: (relative) => ({ path: relative, edits: [{ paragraph: 0, text: '書き換えた' }] }),
|
||
},
|
||
{
|
||
name: 'EditPptx',
|
||
file: 'output/deck.pptx',
|
||
build: writePptx,
|
||
input: (relative) => ({ path: relative, edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] }),
|
||
},
|
||
];
|
||
|
||
for (const route of ROUTES) {
|
||
it(`${route.name} は、隠し項目を持つ包みを断る(中身は変えない)`, async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, route.file);
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await route.build(filePath);
|
||
hideEntries(filePath, [{ name: 'zzz-hidden.bin', content: Buffer.alloc(64 * 1024, 0) }]);
|
||
const before = fs.readFileSync(filePath);
|
||
|
||
const result = await executeTool(route.name, route.input(route.file), makeContext(workspacePath));
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('Refusing to open this package');
|
||
expect(fs.readFileSync(filePath).equals(before)).toBe(true);
|
||
});
|
||
|
||
it(`${route.name} は、同じ素材の**偽っていない**ほうは断らない`, async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, route.file);
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await route.build(filePath);
|
||
// 同じ項目を、件数を偽らずに足す(正直な包み)
|
||
const zip = new AdmZip(filePath);
|
||
zip.addFile('zzz-hidden.bin', Buffer.alloc(64 * 1024, 0));
|
||
zip.writeZip(filePath);
|
||
|
||
const result = await executeTool(route.name, route.input(route.file), makeContext(workspacePath));
|
||
|
||
expect(result?.output ?? '').not.toContain('Refusing to open this package');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 詳細編集(operations)は共通の関所ではなく preflight を通るが、**隠した項目が
|
||
* 実害を出すのはこの経路**(列挙が申告件数で止まったまま ExcelJS へ渡り、
|
||
* あちらは目録を最後まで読む)。同じ規則が乗っていることを固定する。
|
||
*/
|
||
it('EditExcel の詳細編集も、隠し項目を持つ包みを断る(増幅が起きる経路)', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, 'input', 'book.xlsx');
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await writeWorkbook(filePath);
|
||
hideEntries(filePath, [{ name: 'zzz-hidden.bin', content: Buffer.alloc(64 * 1024, 0) }]);
|
||
|
||
const result = await executeTool('EditExcel', {
|
||
path: 'input/book.xlsx',
|
||
output_path: 'output/planned.xlsx',
|
||
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A2', values: [['計画']] }],
|
||
}, makeContext(workspacePath));
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('食い違います');
|
||
expect(fs.existsSync(path.join(workspacePath, 'output', 'planned.xlsx'))).toBe(false);
|
||
});
|
||
|
||
it('EditExcel の詳細編集は、偽っていないブックなら断らない', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, 'input', 'book.xlsx');
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await writeWorkbook(filePath);
|
||
|
||
const result = await executeTool('EditExcel', {
|
||
path: 'input/book.xlsx',
|
||
output_path: 'output/planned.xlsx',
|
||
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A2', values: [['計画']] }],
|
||
}, makeContext(workspacePath));
|
||
|
||
expect(result?.isError).toBe(false);
|
||
expect(await readCell(path.join(workspacePath, 'output', 'planned.xlsx'), 'A2')).toBe('計画');
|
||
});
|
||
|
||
it('ZIP64 の包みは、どの入口でも誤って断られない', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, 'output', 'book.xlsx');
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await writeWorkbook(filePath);
|
||
rewriteAsZip64(filePath);
|
||
|
||
const result = await executeTool(
|
||
'EditExcel',
|
||
{ path: 'output/book.xlsx', edits: [{ cell: 'B1', value: 42 }] },
|
||
makeContext(workspacePath),
|
||
);
|
||
|
||
expect(result?.isError).toBeFalsy();
|
||
expect(await readCell(filePath, 'B1')).toBe(42);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 申告する**展開後サイズ**を偽った包みを、関所が乗っている入口すべてで断る
|
||
* (#1084 段階 3)。
|
||
*
|
||
* 段階 1 で塞いだのは「項目の**件数**を偽る」形。ここで塞ぐのは「1 項目の
|
||
* **大きさ**を偽る」形。どちらも同じ非対称——関所が使う道具(adm-zip)は
|
||
* 申告ぶんしか受け皿を作らず、読み込み層(JSZip)は最後まで展開する。
|
||
*
|
||
* 経路は上と同じ表を使う。入口を足したら、そちらに 1 行足せば両方の細工で
|
||
* 見られる。
|
||
*/
|
||
describe('office tools — 展開後サイズの申告を偽った包みを断る(段階 3)', () => {
|
||
let workspacePath = '';
|
||
|
||
afterEach(() => {
|
||
if (workspacePath) {
|
||
fs.rmSync(workspacePath, { recursive: true, force: true });
|
||
workspacePath = '';
|
||
}
|
||
});
|
||
|
||
interface Route {
|
||
name: string;
|
||
file: string;
|
||
build: (filePath: string) => Promise<void> | void;
|
||
input: (relative: string) => Record<string, unknown>;
|
||
}
|
||
|
||
const ROUTES: Route[] = [
|
||
{
|
||
name: 'ReadPPTX',
|
||
file: 'input/deck.pptx',
|
||
build: writePptx,
|
||
input: (relative) => ({ path: relative }),
|
||
},
|
||
{
|
||
name: 'EditExcel',
|
||
file: 'output/book.xlsx',
|
||
build: writeWorkbook,
|
||
input: (relative) => ({ path: relative, edits: [{ cell: 'B1', value: 42 }] }),
|
||
},
|
||
{
|
||
name: 'EditDocx',
|
||
file: 'output/report.docx',
|
||
build: writeDocx,
|
||
input: (relative) => ({ path: relative, edits: [{ paragraph: 0, text: '書き換えた' }] }),
|
||
},
|
||
{
|
||
name: 'EditPptx',
|
||
file: 'output/deck.pptx',
|
||
build: writePptx,
|
||
input: (relative) => ({ path: relative, edits: [{ slide: 1, paragraph: 0, text: '書き換えた' }] }),
|
||
},
|
||
];
|
||
|
||
/** 開くと 8MB 膨らむ項目を足し、その申告だけ 100 バイトに偽る。 */
|
||
async function buildLiar(route: Route): Promise<string> {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, route.file);
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await route.build(filePath);
|
||
const zip = new AdmZip(filePath);
|
||
zip.addFile('zzz-payload.bin', Buffer.alloc(8 * MB, 0));
|
||
zip.writeZip(filePath);
|
||
lieAboutUncompressedSize(filePath, 'zzz-payload.bin', 100);
|
||
return filePath;
|
||
}
|
||
|
||
for (const route of ROUTES) {
|
||
it(`${route.name} は、展開後サイズを偽った包みを断る(中身は変えない)`, async () => {
|
||
const filePath = await buildLiar(route);
|
||
const before = fs.readFileSync(filePath);
|
||
// ディスク上の大きさでも件数でも弾けないことを、素材の側で確かめる。
|
||
expect(fs.statSync(filePath).size).toBeLessThan(MB);
|
||
expect(new AdmZip(filePath).getEntries().reduce((sum, e) => sum + e.header.size, 0))
|
||
.toBeLessThan(MB);
|
||
|
||
const result = await executeTool(
|
||
route.name,
|
||
route.input(route.file),
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 } as ToolsConfig),
|
||
);
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(result?.output).toContain('ZIP bomb detected');
|
||
expect(fs.readFileSync(filePath).equals(before)).toBe(true);
|
||
});
|
||
|
||
it(`${route.name} は、同じ素材の**偽っていない**ほうは上限内なら断らない`, async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, route.file);
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await route.build(filePath);
|
||
const zip = new AdmZip(filePath);
|
||
zip.addFile('zzz-payload.bin', Buffer.alloc(8 * MB, 0));
|
||
zip.writeZip(filePath);
|
||
|
||
const result = await executeTool(
|
||
route.name,
|
||
route.input(route.file),
|
||
makeContext(workspacePath, { officePptxMaxUncompressedMb: 20 } as ToolsConfig),
|
||
);
|
||
|
||
expect(result?.output ?? '').not.toContain('ZIP bomb detected');
|
||
expect(result?.isError ?? false).toBe(false);
|
||
});
|
||
}
|
||
|
||
it('EditExcel の詳細編集も、展開後サイズを偽った包みを断る', async () => {
|
||
workspacePath = makeWorkspace();
|
||
const filePath = path.join(workspacePath, 'input', 'book.xlsx');
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
await writeWorkbook(filePath);
|
||
const zip = new AdmZip(filePath);
|
||
zip.addFile('zzz-payload.bin', Buffer.alloc(8 * MB, 0));
|
||
zip.writeZip(filePath);
|
||
lieAboutUncompressedSize(filePath, 'zzz-payload.bin', 100);
|
||
|
||
const result = await executeTool('EditExcel', {
|
||
path: 'input/book.xlsx',
|
||
output_path: 'output/planned.xlsx',
|
||
operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A2', values: [['計画']] }],
|
||
}, makeContext(workspacePath, { officePptxMaxUncompressedMb: 1 } as ToolsConfig));
|
||
|
||
expect(result?.isError).toBe(true);
|
||
expect(fs.existsSync(path.join(workspacePath, 'output', 'planned.xlsx'))).toBe(false);
|
||
});
|
||
});
|