245 lines
7.6 KiB
TypeScript
245 lines
7.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
OdfParseError,
|
|
parseOdt,
|
|
parseOpenDocument,
|
|
} from "../../../src/office/odf";
|
|
import {
|
|
corruptFirstCentralCrc,
|
|
makeManifest,
|
|
makeOdf,
|
|
MEDIA_TYPES,
|
|
odtContent,
|
|
odsContent,
|
|
XMLNS,
|
|
zipWithEncodedCollision,
|
|
zipWithTraversal,
|
|
} from "./fixtures";
|
|
|
|
function expectOdfError(
|
|
action: () => unknown,
|
|
code: OdfParseError["code"],
|
|
): void {
|
|
try {
|
|
action();
|
|
throw new Error("Expected ODF parser to reject the fixture");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(OdfParseError);
|
|
expect((error as OdfParseError).code).toBe(code);
|
|
}
|
|
}
|
|
|
|
describe("OpenDocument package and XML security boundaries", () => {
|
|
it("rejects non-ZIP input and unsafe traversal paths", () => {
|
|
expectOdfError(
|
|
() => parseOpenDocument(new Uint8Array([1, 2, 3]).buffer),
|
|
"invalid-zip",
|
|
);
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(
|
|
zipWithTraversal(odtContent("<text:p>Safe</text:p>")),
|
|
),
|
|
"invalid-path",
|
|
);
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(
|
|
zipWithEncodedCollision(odtContent("<text:p>Safe</text:p>")),
|
|
),
|
|
"invalid-path",
|
|
);
|
|
});
|
|
|
|
it("rejects CRC corruption", () => {
|
|
const fixture = makeOdf("odt", odtContent("<text:p>Integrity</text:p>"));
|
|
expectOdfError(
|
|
() => parseOpenDocument(corruptFirstCentralCrc(fixture)),
|
|
"invalid-zip",
|
|
);
|
|
});
|
|
|
|
it("rejects expected-format, filename, manifest, and body type mismatches", () => {
|
|
const ods = makeOdf("ods", odsContent('<table:table table:name="S"/>'));
|
|
expectOdfError(
|
|
() => parseOpenDocument(ods, { expectedFormat: "odt" }),
|
|
"type-mismatch",
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(ods, { fileName: "wrong.odt" }),
|
|
"type-mismatch",
|
|
);
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(
|
|
makeOdf("odt", odtContent("<text:p>x</text:p>"), {
|
|
manifest: makeManifest(MEDIA_TYPES.ods),
|
|
}),
|
|
),
|
|
"type-mismatch",
|
|
);
|
|
expectOdfError(
|
|
() =>
|
|
parseOdt(makeOdf("odt", odsContent('<table:table table:name="S"/>'))),
|
|
"type-mismatch",
|
|
);
|
|
});
|
|
|
|
it("rejects encrypted package declarations", () => {
|
|
const manifest = `<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
|
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MEDIA_TYPES.odt}"/>
|
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml">
|
|
<manifest:encryption-data/>
|
|
</manifest:file-entry></manifest:manifest>`;
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(
|
|
makeOdf("odt", odtContent("<text:p>x</text:p>"), { manifest }),
|
|
),
|
|
"encrypted",
|
|
);
|
|
});
|
|
|
|
it("accepts manifest directories represented implicitly by child entries", () => {
|
|
const manifest = `<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
|
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MEDIA_TYPES.odt}"/>
|
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
|
<manifest:file-entry manifest:full-path="Object 1/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
|
</manifest:manifest>`;
|
|
const document = parseOpenDocument(
|
|
makeOdf("odt", odtContent("<text:p>Safe</text:p>"), {
|
|
manifest,
|
|
extra: {
|
|
"Object 1/content.xml": new TextEncoder().encode("unused"),
|
|
},
|
|
}),
|
|
);
|
|
expect(document.format).toBe("odt");
|
|
});
|
|
|
|
it("rejects malformed XML, DTDs/entities, and excessive XML depth", () => {
|
|
expectOdfError(
|
|
() => parseOpenDocument(makeOdf("odt", "<office:document-content>")),
|
|
"invalid-xml",
|
|
);
|
|
const entity = `<?xml version="1.0"?><!DOCTYPE x [<!ENTITY local "bad">]>
|
|
<office:document-content ${XMLNS}><office:body><office:text><text:p>&local;</text:p></office:text></office:body></office:document-content>`;
|
|
expectOdfError(
|
|
() => parseOpenDocument(makeOdf("odt", entity)),
|
|
"invalid-xml",
|
|
);
|
|
const nested =
|
|
`<text:section>`.repeat(8) +
|
|
`<text:p>x</text:p>` +
|
|
`</text:section>`.repeat(8);
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(makeOdf("odt", odtContent(nested)), {
|
|
limits: { maxXmlDepth: 6 },
|
|
}),
|
|
"limit-exceeded",
|
|
);
|
|
const compact = `<?xml version="1.0"?><office:document-content ${XMLNS}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>`;
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(makeOdf("odt", compact, { manifest: false }), {
|
|
limits: { maxXmlNodes: 4 },
|
|
}),
|
|
"limit-exceeded",
|
|
);
|
|
});
|
|
|
|
it("rejects dangerous external images and active links", () => {
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(
|
|
makeOdf(
|
|
"odt",
|
|
odtContent(
|
|
'<draw:frame><draw:image xlink:href="https://tracker.example/image.png"/></draw:frame>',
|
|
),
|
|
),
|
|
),
|
|
"external-resource",
|
|
);
|
|
expectOdfError(
|
|
() =>
|
|
parseOpenDocument(
|
|
makeOdf(
|
|
"odt",
|
|
odtContent(
|
|
'<text:p><text:a xlink:href="javascript:alert(1)">bad</text:a></text:p>',
|
|
),
|
|
),
|
|
),
|
|
"external-resource",
|
|
);
|
|
});
|
|
|
|
it("keeps embedded objects inert and reports their omission", () => {
|
|
const document = parseOpenDocument(
|
|
makeOdf("odt", odtContent('<draw:object xlink:href="Object 1"/>')),
|
|
);
|
|
if (document.format !== "odt") throw new Error("Expected an ODT model");
|
|
expect(document.blocks).toEqual([]);
|
|
expect(document.warnings).toContain(
|
|
"1 embedded object was kept inert and omitted",
|
|
);
|
|
});
|
|
|
|
it("enforces entry, total, text, asset, repeat, and expanded-cell limits", () => {
|
|
const basic = makeOdf("odt", odtContent("<text:p>123456789</text:p>"));
|
|
expectOdfError(
|
|
() => parseOpenDocument(basic, { limits: { maxEntryBytes: 32 } }),
|
|
"limit-exceeded",
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(basic, { limits: { maxTotalBytes: 50 } }),
|
|
"limit-exceeded",
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(basic, { limits: { maxEntries: 2 } }),
|
|
"limit-exceeded",
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(basic, { limits: { maxTextChars: 5 } }),
|
|
"limit-exceeded",
|
|
);
|
|
|
|
const repeated = makeOdf(
|
|
"ods",
|
|
odsContent(
|
|
'<table:table table:name="Huge"><table:table-row table:number-rows-repeated="4"><table:table-cell table:number-columns-repeated="4" office:value-type="string"><text:p>x</text:p></table:table-cell></table:table-row></table:table>',
|
|
),
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(repeated, { limits: { maxRepeat: 3 } }),
|
|
"limit-exceeded",
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(repeated, { limits: { maxCells: 15 } }),
|
|
"limit-exceeded",
|
|
);
|
|
|
|
const image = new Uint8Array([137, 80, 78, 71, 1, 2, 3, 4]);
|
|
const imageDocument = makeOdf(
|
|
"odt",
|
|
odtContent(
|
|
'<draw:frame><draw:image xlink:href="Pictures/a.png"/></draw:frame>',
|
|
),
|
|
{ extra: { "Pictures/a.png": image } },
|
|
);
|
|
expectOdfError(
|
|
() => parseOpenDocument(imageDocument, { limits: { maxAssetBytes: 4 } }),
|
|
"limit-exceeded",
|
|
);
|
|
});
|
|
|
|
it("validates option limits before reading untrusted input", () => {
|
|
expect(() =>
|
|
parseOpenDocument(new ArrayBuffer(0), { limits: { maxEntries: 0 } }),
|
|
).toThrow(TypeError);
|
|
});
|
|
});
|