feat: add safe OneNote export serializers
This commit is contained in:
453
src/onenote/export/export.test.ts
Normal file
453
src/onenote/export/export.test.ts
Normal 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`<script>bad\(\)</script>`
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user