Files
maestro/src/engine/tools/office.size-limits.test.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

835 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});