fix: bound browser image previews
This commit is contained in:
119
src/onenote/one/content-image-safety.test.ts
Normal file
119
src/onenote/one/content-image-safety.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_ONENOTE_PARSER_LIMITS } from '../parser/limits.js';
|
||||
import type {
|
||||
ExGuid,
|
||||
ObjectPropSet,
|
||||
ObjectSpace,
|
||||
StoreObject,
|
||||
} from '../onestore/types.js';
|
||||
import { exGuidKey } from '../onestore/types.js';
|
||||
import { parseContentObjects } from './content.js';
|
||||
import { JCID, PROPERTY } from './constants.js';
|
||||
|
||||
const GUID = '{44444444-4444-4444-4444-444444444444}';
|
||||
|
||||
describe('content image preview safety', () => {
|
||||
it('keeps an oversized image downloadable while disabling auto-rendering', () => {
|
||||
const imageId = id(1);
|
||||
const containerId = id(2);
|
||||
const payload = hugePng();
|
||||
const image = object(imageId, JCID.ImageNode, {
|
||||
...emptyProps(),
|
||||
objectIds: [{ guidIndex: 1, value: 2 }],
|
||||
properties: {
|
||||
entries: [
|
||||
{
|
||||
rawId: PROPERTY.PictureContainer,
|
||||
id: PROPERTY.PictureContainer & 0x03ff_ffff,
|
||||
type: 0x8,
|
||||
value: { kind: 'objectId' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const container = object(containerId, JCID.PictureContainer, emptyProps());
|
||||
container.fileData = {
|
||||
referenceKind: 'embedded',
|
||||
dataReference: '<ifndf>{44444444-4444-4444-4444-444444444444}',
|
||||
extension: '.png',
|
||||
blob: { source: payload, offset: 0, size: payload.byteLength },
|
||||
};
|
||||
const resources = new Map();
|
||||
const space: ObjectSpace = {
|
||||
id: id(0),
|
||||
roots: new Map(),
|
||||
objects: new Map([
|
||||
[exGuidKey(image.id), image],
|
||||
[exGuidKey(container.id), container],
|
||||
]),
|
||||
};
|
||||
|
||||
const blocks = parseContentObjects([imageId], {
|
||||
space,
|
||||
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS },
|
||||
diagnostics: [],
|
||||
resources,
|
||||
});
|
||||
|
||||
expect(blocks).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'image',
|
||||
resourceId: exGuidKey(containerId),
|
||||
}),
|
||||
]);
|
||||
expect(resources.get(exGuidKey(containerId))).toMatchObject({
|
||||
kind: 'image',
|
||||
mediaType: 'image/png',
|
||||
browserRenderable: false,
|
||||
availability: 'available',
|
||||
size: payload.byteLength,
|
||||
blob: { source: payload, offset: 0, size: payload.byteLength },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function id(value: number): ExGuid {
|
||||
return { guid: GUID, value };
|
||||
}
|
||||
|
||||
function object(
|
||||
objectId: ExGuid,
|
||||
jcid: number,
|
||||
props: ObjectPropSet
|
||||
): StoreObject {
|
||||
return {
|
||||
id: objectId,
|
||||
jcid,
|
||||
props,
|
||||
mapping: new Map([[1, GUID]]),
|
||||
contextId: id(0),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProps(): ObjectPropSet {
|
||||
return {
|
||||
objectIds: [],
|
||||
objectSpaceIds: [],
|
||||
contextIds: [],
|
||||
properties: { entries: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function hugePng(): Uint8Array {
|
||||
const bytes = new Uint8Array(33);
|
||||
bytes.set([0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10], 0);
|
||||
writeU32Be(bytes, 8, 13);
|
||||
bytes.set([0x49, 0x48, 0x44, 0x52], 12);
|
||||
writeU32Be(bytes, 16, 0xffff_ffff);
|
||||
writeU32Be(bytes, 20, 1);
|
||||
bytes.set([8, 6, 0, 0, 0], 24);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function writeU32Be(bytes: Uint8Array, offset: number, value: number): void {
|
||||
bytes[offset] = (value >>> 24) & 0xff;
|
||||
bytes[offset + 1] = (value >>> 16) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 3] = value & 0xff;
|
||||
}
|
||||
124
src/onenote/one/content-table-safety.test.ts
Normal file
124
src/onenote/one/content-table-safety.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
import { DEFAULT_ONENOTE_PARSER_LIMITS } from '../parser/limits.js';
|
||||
import type {
|
||||
ExGuid,
|
||||
ObjectPropSet,
|
||||
ObjectSpace,
|
||||
PropertyEntry,
|
||||
StoreObject,
|
||||
} from '../onestore/types.js';
|
||||
import { exGuidKey } from '../onestore/types.js';
|
||||
import { parseContentObjects } from './content.js';
|
||||
import { JCID, PROPERTY } from './constants.js';
|
||||
|
||||
const GUID = '{55555555-5555-5555-5555-555555555555}';
|
||||
|
||||
describe('content table allocation safety', () => {
|
||||
it('rejects a max-u32 declared column count before bitmap allocation', () => {
|
||||
const tableId: ExGuid = { guid: GUID, value: 1 };
|
||||
const table = object(
|
||||
tableId,
|
||||
JCID.TableNode,
|
||||
props(
|
||||
u32Entry(PROPERTY.RowCount, 1),
|
||||
u32Entry(PROPERTY.ColumnCount, 0xffff_ffff),
|
||||
bytesEntry(PROPERTY.TableColumnsLocked, Uint8Array.of(1, 1))
|
||||
)
|
||||
);
|
||||
const diagnostics: ParserDiagnostic[] = [];
|
||||
const space: ObjectSpace = {
|
||||
id: { guid: GUID, value: 0 },
|
||||
roots: new Map(),
|
||||
objects: new Map([[exGuidKey(table.id), table]]),
|
||||
};
|
||||
|
||||
expect(
|
||||
parseContentObjects([tableId], {
|
||||
space,
|
||||
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS },
|
||||
diagnostics,
|
||||
resources: new Map(),
|
||||
})
|
||||
).toEqual([]);
|
||||
expect(diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'CONTENT_PARSE_FAILED',
|
||||
message: expect.stringContaining(
|
||||
'Declared table column count exceeds 1000000'
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects declared table dimensions whose product exceeds the cell limit', () => {
|
||||
const tableId: ExGuid = { guid: GUID, value: 2 };
|
||||
const table = object(
|
||||
tableId,
|
||||
JCID.TableNode,
|
||||
props(
|
||||
u32Entry(PROPERTY.RowCount, 1_001),
|
||||
u32Entry(PROPERTY.ColumnCount, 1_000)
|
||||
)
|
||||
);
|
||||
const diagnostics: ParserDiagnostic[] = [];
|
||||
|
||||
expect(
|
||||
parseContentObjects([tableId], {
|
||||
space: {
|
||||
id: { guid: GUID, value: 0 },
|
||||
roots: new Map(),
|
||||
objects: new Map([[exGuidKey(table.id), table]]),
|
||||
},
|
||||
limits: { ...DEFAULT_ONENOTE_PARSER_LIMITS },
|
||||
diagnostics,
|
||||
resources: new Map(),
|
||||
})
|
||||
).toEqual([]);
|
||||
expect(diagnostics[0]?.message).toContain(
|
||||
'Declared table cell count exceeds 1000000'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function object(
|
||||
id: ExGuid,
|
||||
jcid: number,
|
||||
objectProps: ObjectPropSet
|
||||
): StoreObject {
|
||||
return {
|
||||
id,
|
||||
jcid,
|
||||
props: objectProps,
|
||||
mapping: new Map(),
|
||||
contextId: { guid: GUID, value: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function props(...entries: PropertyEntry[]): ObjectPropSet {
|
||||
return {
|
||||
objectIds: [],
|
||||
objectSpaceIds: [],
|
||||
contextIds: [],
|
||||
properties: { entries },
|
||||
};
|
||||
}
|
||||
|
||||
function u32Entry(rawId: number, value: number): PropertyEntry {
|
||||
return {
|
||||
rawId,
|
||||
id: rawId & 0x03ff_ffff,
|
||||
type: 0x4,
|
||||
value: { kind: 'u32', value },
|
||||
};
|
||||
}
|
||||
|
||||
function bytesEntry(rawId: number, value: Uint8Array): PropertyEntry {
|
||||
return {
|
||||
rawId,
|
||||
id: rawId & 0x03ff_ffff,
|
||||
type: 0x7,
|
||||
value: { kind: 'bytes', value },
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||
import { readGuid } from '../binary/guid.js';
|
||||
import { inspectBrowserImage } from '../image-safety.js';
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||
import type {
|
||||
@@ -504,7 +505,11 @@ function registerResource(
|
||||
const extension = normalizeExtension(
|
||||
object.fileData?.extension ?? extensionFromFilename(filename)
|
||||
);
|
||||
const sniffed = sniffResource(object.fileData?.blob, extension);
|
||||
const sniffed = sniffResource(
|
||||
object.fileData?.blob,
|
||||
extension,
|
||||
context.limits
|
||||
);
|
||||
const availability = resourceAvailability(object);
|
||||
context.resources.set(key, {
|
||||
id: key,
|
||||
@@ -529,6 +534,11 @@ function parseTable(
|
||||
): OneNoteTableBlock {
|
||||
const declaredRowCount = optionalU32(object, PROPERTY.RowCount);
|
||||
const declaredColumnCount = optionalU32(object, PROPERTY.ColumnCount);
|
||||
validateDeclaredTableCounts(
|
||||
declaredRowCount,
|
||||
declaredColumnCount,
|
||||
context.limits.maxTableCells
|
||||
);
|
||||
const rowIds = objectReferences(object, PROPERTY.ElementChildNodes);
|
||||
if (declaredRowCount !== undefined && declaredRowCount !== rowIds.length) {
|
||||
addDiagnostic(
|
||||
@@ -1014,7 +1024,7 @@ function parseLockedColumns(
|
||||
);
|
||||
}
|
||||
const bitmap = bytes.subarray(1, Math.min(bytes.length, byteCount + 1));
|
||||
const count = columnCount ?? bitCount;
|
||||
const count = Math.min(columnCount ?? bitCount, context.limits.maxTableCells);
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
@@ -1022,26 +1032,46 @@ function parseLockedColumns(
|
||||
);
|
||||
}
|
||||
|
||||
function validateDeclaredTableCounts(
|
||||
rowCount: number | undefined,
|
||||
columnCount: number | undefined,
|
||||
maxTableCells: number
|
||||
): void {
|
||||
if (rowCount !== undefined && rowCount > maxTableCells) {
|
||||
throw new Error(`Declared table row count exceeds ${maxTableCells}`);
|
||||
}
|
||||
if (columnCount !== undefined && columnCount > maxTableCells) {
|
||||
throw new Error(`Declared table column count exceeds ${maxTableCells}`);
|
||||
}
|
||||
if (
|
||||
rowCount !== undefined &&
|
||||
columnCount !== undefined &&
|
||||
columnCount > 0 &&
|
||||
rowCount > Math.floor(maxTableCells / columnCount)
|
||||
) {
|
||||
throw new Error(`Declared table cell count exceeds ${maxTableCells}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sniffResource(
|
||||
blob: FileDataBlobReference | undefined,
|
||||
extension: string | undefined
|
||||
extension: string | undefined,
|
||||
limits: OneNoteParserLimits
|
||||
): { mediaType: string; browserRenderable: boolean } {
|
||||
if (blob && blob.size >= 8) {
|
||||
const bytes = blob.source.subarray(blob.offset, blob.offset + 12);
|
||||
if (
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47 &&
|
||||
bytes[4] === 0x0d &&
|
||||
bytes[5] === 0x0a &&
|
||||
bytes[6] === 0x1a &&
|
||||
bytes[7] === 0x0a
|
||||
) {
|
||||
return { mediaType: 'image/png', browserRenderable: true };
|
||||
}
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return { mediaType: 'image/jpeg', browserRenderable: true };
|
||||
if (blob) {
|
||||
const inspected = inspectBrowserImage(
|
||||
blob.source.subarray(blob.offset, blob.offset + blob.size),
|
||||
{
|
||||
maxBytes: limits.maxAutoRenderImageBytes,
|
||||
maxDimension: limits.maxAutoRenderImageDimension,
|
||||
maxPixels: limits.maxAutoRenderImagePixels,
|
||||
}
|
||||
);
|
||||
if (inspected.mediaType) {
|
||||
return {
|
||||
mediaType: inspected.mediaType,
|
||||
browserRenderable: inspected.browserRenderable,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user