Files
office-tools/tests/office/odf/security.test.ts
T

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);
});
});