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 { 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 => { 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('', 'utf-8')); zip.addFile('word/document.xml', Buffer.from( 'もとの本文', 'utf-8')); zip.writeZip(filePath); } function writePptx(filePath: string): void { const zip = new AdmZip(); zip.addFile('[Content_Types].xml', Buffer.from('', 'utf-8')); zip.addFile('ppt/presentation.xml', Buffer.from( '', 'utf-8')); zip.addFile('ppt/_rels/presentation.xml.rels', Buffer.from( '', 'utf-8')); zip.addFile('ppt/slides/slide1.xml', Buffer.from( '' + 'もとの見出し' + '', '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; input: (relative: string) => Record; } 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; input: (relative: string) => Record; } 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 { 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); }); });