This commit is contained in:
@@ -3,8 +3,10 @@ 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) ---
|
||||
|
||||
@@ -62,6 +64,45 @@ function writeZipBomb(filePath: string, uncompressedBytes: number): void {
|
||||
|
||||
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 = '';
|
||||
|
||||
@@ -243,3 +284,551 @@ describe('office tools — file size limits', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user