feat: add safe OneNote export serializers

This commit is contained in:
2026-07-22 19:36:20 +02:00
parent f0f6e48188
commit ece969e269
7 changed files with 2328 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
import { strFromU8, unzipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type {
OneNoteContentBlock,
OneNoteTextBlock,
OneNoteTextStyle,
} from '../one/content-model.js';
import {
createOneNoteExportSnapshot,
createOneNoteStaticNotebookZip,
OneNoteExportError,
SafeExportNameAllocator,
sanitizeExportFilename,
serializeOneNoteMarkdown,
serializeOneNotePlainText,
serializeOneNoteSemanticHtml,
serializeOneNoteStructuredJson,
type OneNoteExportSnapshot,
} from './index.js';
const PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00,
]);
const JPEG = Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]);
const SOURCE_MARKUP = '<img src=x onerror=alert(1)><script>bad()</script>';
describe('OneNote export serializers', () => {
it('retains provenance and rich content without emitting active source markup', () => {
const snapshot = fixtureSnapshot();
const structured = JSON.parse(serializeOneNoteStructuredJson(snapshot)) as {
app: { version: string };
parser: { version: string };
supportLevel: string;
source: { name: string };
diagnostics: ParserDiagnostic[];
warnings: Array<{ code: string }>;
unsupportedContent: Array<{ sourceJcid: number }>;
resources: Array<Record<string, unknown>>;
};
expect(structured).toMatchObject({
app: { version: '1.2.3' },
parser: { version: '9.8.7' },
supportLevel: 'substantial',
source: { name: '../../<script>source.onepkg' },
});
expect(structured.diagnostics.map(({ code }) => code)).toEqual([
'package-warning',
'section-warning',
'page-warning',
]);
expect(
structured.resources.every((resource) => !('bytes' in resource))
).toBe(true);
expect(structured.unsupportedContent).toEqual([
expect.objectContaining({ sourceJcid: 0x1234 }),
]);
expect(structured.warnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
'UNSUPPORTED_IMAGE_RESOURCE',
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
'BLOCK_RESOURCE_UNAVAILABLE',
])
);
const plain = serializeOneNotePlainText(snapshot);
expect(plain).toContain('Application: OneNote Tools 1.2.3');
expect(plain).toContain('Parser: TypeScript parser 9.8.7');
expect(plain).toContain(SOURCE_MARKUP);
expect(plain).toContain('Fallback page text');
expect(plain).toContain('Ink drawing: 1 stroke, 2 points');
const markdown = serializeOneNoteMarkdown(snapshot);
expect(markdown).not.toContain('<script>');
expect(markdown).not.toContain('<img src=x');
expect(markdown).not.toContain('javascript:');
expect(markdown).toContain(
String.raw`&lt;script&gt;bad\(\)&lt;/script&gt;`
);
expect(markdown).toContain('Fallback page text');
expect(markdown).toContain('| Column 1 | Column 2 |');
const html = serializeOneNoteSemanticHtml(snapshot);
const document = new DOMParser().parseFromString(html, 'text/html');
expect(
document.querySelectorAll('script,object,iframe,embed')
).toHaveLength(0);
expect(
document.querySelectorAll(
'[onload],[onerror],[onclick],foreignObject,use'
)
).toHaveLength(0);
expect(document.querySelectorAll('a[href^="javascript:"]')).toHaveLength(0);
expect(document.body.textContent).toContain(SOURCE_MARKUP);
expect(document.body.textContent).toContain('Fallback page text');
expect(document.querySelectorAll('table td')).toHaveLength(2);
expect(document.querySelector('.text strong')?.textContent).toContain(
SOURCE_MARKUP
);
expect(document.querySelectorAll('img')).toHaveLength(0);
expect(document.querySelectorAll('svg.ink')).toHaveLength(1);
expect(
document.querySelector('polyline')?.getAttribute('stroke-opacity')
).toBe('0.496');
expect(
document
.querySelector('meta[http-equiv="Content-Security-Policy"]')
?.getAttribute('content')
).toContain("default-src 'none'");
});
it('creates a deterministic, path-safe static notebook ZIP', () => {
const snapshot = fixtureSnapshot();
const first = createOneNoteStaticNotebookZip(snapshot);
const second = createOneNoteStaticNotebookZip(snapshot);
expect(first.filename).toBe('CON_script_.zip');
expect(first.mediaType).toBe('application/zip');
expect(first.bytes).toEqual(second.bytes);
expect(first.manifest.counts).toMatchObject({
sections: 1,
pages: 2,
resources: 3,
unsupportedContent: 1,
});
const files = unzipSync(first.bytes);
expect(Object.keys(files).sort()).toEqual([
'assets/_CON/CON_script_.html.bin',
'assets/_CON/photo.jpg',
'assets/_CON/photo.png',
'index.html',
'manifest.json',
'notebook.json',
'notebook.md',
'notebook.txt',
'warnings.json',
]);
for (const path of Object.keys(files)) {
expect(path.startsWith('/')).toBe(false);
expect(path).not.toContain('\\');
expect(path.split('/')).not.toContain('..');
}
expect(files['assets/_CON/photo.png']).toEqual(PNG);
expect(files['assets/_CON/photo.jpg']).toEqual(JPEG);
expect(strFromU8(files['assets/_CON/CON_script_.html.bin']!)).toBe(
'<script>alert(2)</script>'
);
const html = strFromU8(files['index.html']!);
const document = new DOMParser().parseFromString(html, 'text/html');
expect(document.querySelector('img')?.getAttribute('src')).toBe(
'assets/_CON/photo.png'
);
expect(document.querySelector('a[download]')?.getAttribute('href')).toBe(
'assets/_CON/CON_script_.html.bin'
);
expect(
document.querySelectorAll('script,object,iframe,embed')
).toHaveLength(0);
const manifest = JSON.parse(strFromU8(files['manifest.json']!)) as {
app: { version: string };
parser: { version: string };
supportLevel: string;
source: { name: string };
resources: Array<{ path: string; mediaType: string }>;
};
expect(manifest).toMatchObject({
app: { version: '1.2.3' },
parser: { version: '9.8.7' },
supportLevel: 'substantial',
source: { name: '../../<script>source.onepkg' },
});
expect(manifest.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'assets/_CON/photo.png',
mediaType: 'image/png',
}),
expect.objectContaining({
path: 'assets/_CON/photo.jpg',
mediaType: 'image/jpeg',
}),
expect.objectContaining({
path: 'assets/_CON/CON_script_.html.bin',
mediaType: 'application/octet-stream',
}),
])
);
const warnings = JSON.parse(strFromU8(files['warnings.json']!)) as {
diagnostics: ParserDiagnostic[];
exportWarnings: Array<{ code: string }>;
unsupportedContent: Array<{ sourceJcid: number }>;
};
expect(warnings.diagnostics).toHaveLength(3);
expect(warnings.exportWarnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
'UNSUPPORTED_IMAGE_RESOURCE',
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
'BLOCK_RESOURCE_UNAVAILABLE',
])
);
expect(warnings.unsupportedContent).toEqual([
expect.objectContaining({ sourceJcid: 0x1234 }),
]);
});
it('bounds traversal and rejects cyclic content', () => {
const snapshot = fixtureSnapshot();
expect(() =>
serializeOneNotePlainText(snapshot, { limits: { maxPages: 1 } })
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
})
);
const cyclic: Extract<OneNoteContentBlock, { kind: 'outline' }> = {
kind: 'outline',
id: 'cycle',
indents: [],
layout: {},
children: [],
};
cyclic.children.push(cyclic);
snapshot.sections[0]!.pages[0]!.blocks = [cyclic];
expect(() => serializeOneNoteStructuredJson(snapshot)).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'invalid-input',
})
);
});
});
describe('OneNote export filenames', () => {
it('removes traversal, reserved names, controls and directional overrides', () => {
expect(sanitizeExportFilename('../../CON')).toBe('_CON');
expect(sanitizeExportFilename('..\\AUX.txt')).toBe('_AUX.txt');
expect(sanitizeExportFilename('safe\u202ename.txt')).toBe('safename.txt');
expect(sanitizeExportFilename('', '../../NUL')).toBe('_NUL');
expect(sanitizeExportFilename('x'.repeat(200))).toHaveLength(120);
expect(
sanitizeExportFilename(`name.${'x'.repeat(200)}`).length
).toBeLessThanOrEqual(120);
});
it('allocates case-insensitive unique names', () => {
const allocator = new SafeExportNameAllocator();
expect(allocator.allocate('Report.txt')).toBe('Report.txt');
expect(allocator.allocate('report.TXT')).toBe('report-2.txt');
});
});
function fixtureSnapshot(): OneNoteExportSnapshot {
const richText = textBlock('text-1', SOURCE_MARKUP, {
bold: true,
italic: true,
});
richText.runs[0]!.href = 'javascript:alert(1)';
const blocks: OneNoteContentBlock[] = [
richText,
{
kind: 'table',
id: 'table-1',
declaredColumnCount: 2,
rows: [
{
id: 'row-1',
cells: [
{ id: 'cell-1', blocks: [textBlock('cell-text', 'A | B')] },
{ id: 'cell-2', blocks: [textBlock('cell-text-2', 'C')] },
],
},
],
columnWidths: [100, 100],
lockedColumns: [false, false],
bordersVisible: true,
layout: {},
},
{
kind: 'image',
id: 'image-block',
resourceId: 'image-1',
filename: 'source.svg',
altText: '<svg onload=alert(3)>Preview</svg>',
href: 'javascript:alert(4)',
isBackground: false,
layout: {},
},
{
kind: 'image',
id: 'bad-image-block',
resourceId: 'bad-image',
filename: 'bad.svg',
isBackground: false,
layout: {},
},
{
kind: 'image',
id: 'jpeg-image-block',
resourceId: 'image-2',
filename: 'photo.jpeg',
isBackground: false,
layout: {},
},
{
kind: 'attachment',
id: 'attachment-block',
resourceId: 'attachment-1',
filename: '../../CON<script>.html',
layout: {},
},
{
kind: 'ink',
id: 'ink-1',
strokes: [
{
points: [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
],
width: 8,
transparency: 128,
color: 0x332211,
},
],
children: [],
layout: {},
},
{
kind: 'unsupported',
id: 'unsupported-1',
sourceJcid: 0x1234,
description: '<script>Unknown object</script>',
},
];
return createOneNoteExportSnapshot({
sourceName: '../../<script>source.onepkg',
sourceFormat: 'onepkg',
notebookName: '../CON<script>.onepkg',
appName: 'OneNote Tools',
appVersion: '1.2.3',
parser: {
name: 'TypeScript parser',
version: '9.8.7',
implementation: 'typescript',
supportedVariants: ['desktop-store', 'fsshttpb-package-store'],
referenceRevision: 'MS-ONE 16.0',
},
supportLevel: 'substantial',
supportNotes: ['Unsupported objects remain explicit.'],
sections: [
{
id: 'section-1',
name: '../CON',
sourcePath: '../unsafe/Section.one',
pages: [
{
id: 'page-1',
title: '<script>Page</script>',
text: SOURCE_MARKUP,
textPreview: SOURCE_MARKUP,
blocks,
diagnostics: [diagnostic('page-warning')],
},
{
id: 'page-2',
title: 'Fallback',
text: 'Fallback page text <script>must stay text</script>',
textPreview: 'Fallback page text',
blocks: [],
diagnostics: [],
},
],
diagnostics: [diagnostic('section-warning')],
},
],
resources: [
{
sectionId: 'section-1',
id: 'image-1',
kind: 'image',
filename: 'photo.svg',
extension: 'svg',
mediaType: 'image/svg+xml',
browserRenderable: true,
size: PNG.byteLength,
bytes: PNG,
},
{
sectionId: 'section-1',
id: 'bad-image',
kind: 'image',
filename: 'bad.svg',
extension: 'svg',
mediaType: 'image/svg+xml',
browserRenderable: true,
bytes: new TextEncoder().encode('<svg><script>bad()</script></svg>'),
},
{
sectionId: 'section-1',
id: 'image-2',
kind: 'image',
filename: 'photo.png',
extension: 'png',
mediaType: 'image/png',
browserRenderable: true,
bytes: JPEG,
},
{
sectionId: 'section-1',
id: 'attachment-1',
kind: 'attachment',
filename: '../../CON<script>.html',
extension: 'html',
mediaType: 'text/html',
browserRenderable: false,
bytes: new TextEncoder().encode('<script>alert(2)</script>'),
},
],
diagnostics: [diagnostic('package-warning')],
});
}
function textBlock(
id: string,
text: string,
style: OneNoteTextStyle = {}
): OneNoteTextBlock {
return {
kind: 'text',
id,
sourceText: text,
text,
runs: [{ start: 0, end: text.length, text, style }],
paragraphStyle: {},
layout: {},
};
}
function diagnostic(code: string): ParserDiagnostic {
return {
severity: 'warning',
code,
message: `${code} <script>message</script>`,
recoverable: true,
};
}