fix: bound browser image previews
This commit is contained in:
159
src/onenote/image-safety.test.ts
Normal file
159
src/onenote/image-safety.test.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
inspectBrowserImage,
|
||||||
|
} from './image-safety.js';
|
||||||
|
|
||||||
|
describe('browser image safety inspection', () => {
|
||||||
|
it('accepts bounded PNG and JPEG dimensions', () => {
|
||||||
|
expect(inspectBrowserImage(png(640, 480))).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
pixels: 307_200,
|
||||||
|
browserRenderable: true,
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(jpeg(1_920, 1_080))).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
extension: 'jpg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
width: 1_920,
|
||||||
|
height: 1_080,
|
||||||
|
pixels: 2_073_600,
|
||||||
|
browserRenderable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects huge PNG and JPEG dimensions without losing their type', () => {
|
||||||
|
expect(inspectBrowserImage(png(0xffff_ffff, 1))).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
width: 0xffff_ffff,
|
||||||
|
height: 1,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'dimension-limit',
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(jpeg(0xffff, 0xffff))).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
width: 0xffff,
|
||||||
|
height: 0xffff,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'dimension-limit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces decoded pixel and compressed-byte limits independently', () => {
|
||||||
|
expect(
|
||||||
|
inspectBrowserImage(png(8_192, 8_193), {
|
||||||
|
...DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
maxDimension: 10_000,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
pixels: 67_117_056,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'pixel-limit',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
inspectBrowserImage(png(1, 1), {
|
||||||
|
...DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
maxBytes: 32,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'byte-limit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed PNG IHDR and JPEG marker streams', () => {
|
||||||
|
const malformedPng = png(16, 16);
|
||||||
|
malformedPng[11] = 12;
|
||||||
|
const malformedJpeg = jpeg(16, 16);
|
||||||
|
malformedJpeg[4] = 0;
|
||||||
|
malformedJpeg[5] = 1;
|
||||||
|
|
||||||
|
expect(inspectBrowserImage(malformedPng)).toMatchObject({
|
||||||
|
format: 'png',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(malformedJpeg)).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
expect(inspectBrowserImage(Uint8Array.of(0xff, 0xd8, 0xff))).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a JPEG marker walk that never reaches a frame header', () => {
|
||||||
|
const bytes = new Uint8Array(2 + 5_000 * 2);
|
||||||
|
bytes.set([0xff, 0xd8], 0);
|
||||||
|
for (let offset = 2; offset < bytes.length; offset += 2) {
|
||||||
|
bytes[offset] = 0xff;
|
||||||
|
bytes[offset + 1] = 0xd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(inspectBrowserImage(bytes)).toMatchObject({
|
||||||
|
format: 'jpeg',
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function png(width: number, height: number): 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, width);
|
||||||
|
writeU32Be(bytes, 20, height);
|
||||||
|
bytes.set([8, 6, 0, 0, 0], 24);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jpeg(width: number, height: number): Uint8Array {
|
||||||
|
return Uint8Array.of(
|
||||||
|
0xff,
|
||||||
|
0xd8,
|
||||||
|
0xff,
|
||||||
|
0xe0,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0xff,
|
||||||
|
0xc0,
|
||||||
|
0x00,
|
||||||
|
0x11,
|
||||||
|
0x08,
|
||||||
|
(height >>> 8) & 0xff,
|
||||||
|
height & 0xff,
|
||||||
|
(width >>> 8) & 0xff,
|
||||||
|
width & 0xff,
|
||||||
|
0x03,
|
||||||
|
0x01,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0x03,
|
||||||
|
0x11,
|
||||||
|
0x00
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
273
src/onenote/image-safety.ts
Normal file
273
src/onenote/image-safety.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
export interface BrowserImageSafetyLimits {
|
||||||
|
/** Maximum compressed resource size eligible for automatic rendering. */
|
||||||
|
maxBytes: number;
|
||||||
|
/** Maximum supported width or height in pixels. */
|
||||||
|
maxDimension: number;
|
||||||
|
/** Maximum supported decoded pixel count. */
|
||||||
|
maxPixels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS: Readonly<BrowserImageSafetyLimits> =
|
||||||
|
{
|
||||||
|
maxBytes: 32 * 1024 * 1024,
|
||||||
|
maxDimension: 16_384,
|
||||||
|
maxPixels: 64 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserImageRejectionReason =
|
||||||
|
| 'unsupported-format'
|
||||||
|
| 'byte-limit'
|
||||||
|
| 'invalid-header'
|
||||||
|
| 'dimension-limit'
|
||||||
|
| 'pixel-limit';
|
||||||
|
|
||||||
|
export interface BrowserImageInspection {
|
||||||
|
format?: 'png' | 'jpeg';
|
||||||
|
extension?: 'png' | 'jpg';
|
||||||
|
mediaType?: 'image/png' | 'image/jpeg';
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
pixels?: number;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
rejectionReason?: BrowserImageRejectionReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Uint8Array.of(
|
||||||
|
0x89,
|
||||||
|
0x50,
|
||||||
|
0x4e,
|
||||||
|
0x47,
|
||||||
|
0x0d,
|
||||||
|
0x0a,
|
||||||
|
0x1a,
|
||||||
|
0x0a
|
||||||
|
);
|
||||||
|
const PNG_IHDR_SIZE = 33;
|
||||||
|
const JPEG_HEADER_SCAN_BYTES = 1024 * 1024;
|
||||||
|
const JPEG_MARKER_LIMIT = 4_096;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect untrusted PNG/JPEG bytes without decoding pixels. The JPEG walk is
|
||||||
|
* capped independently of the resource size so malformed marker streams
|
||||||
|
* cannot turn preview eligibility checks into an unbounded scan.
|
||||||
|
*/
|
||||||
|
export function inspectBrowserImage(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: BrowserImageSafetyLimits = DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS
|
||||||
|
): BrowserImageInspection {
|
||||||
|
validateLimits(limits);
|
||||||
|
|
||||||
|
let identity:
|
||||||
|
| {
|
||||||
|
format: 'png' | 'jpeg';
|
||||||
|
extension: 'png' | 'jpg';
|
||||||
|
mediaType: 'image/png' | 'image/jpeg';
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let dimensions: ImageDimensions | undefined;
|
||||||
|
|
||||||
|
if (hasPngSignature(bytes)) {
|
||||||
|
identity = {
|
||||||
|
format: 'png',
|
||||||
|
extension: 'png',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
};
|
||||||
|
dimensions = readPngDimensions(bytes);
|
||||||
|
} else if (hasJpegSignature(bytes)) {
|
||||||
|
identity = {
|
||||||
|
format: 'jpeg',
|
||||||
|
extension: 'jpg',
|
||||||
|
mediaType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
dimensions = readJpegDimensions(bytes);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'unsupported-format',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dimensions) {
|
||||||
|
return {
|
||||||
|
...identity,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'invalid-header',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = dimensions;
|
||||||
|
const pixels =
|
||||||
|
width <= Math.floor(Number.MAX_SAFE_INTEGER / height)
|
||||||
|
? width * height
|
||||||
|
: undefined;
|
||||||
|
const details = { ...identity, width, height, pixels };
|
||||||
|
|
||||||
|
if (bytes.byteLength > limits.maxBytes) {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'byte-limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (width > limits.maxDimension || height > limits.maxDimension) {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'dimension-limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pixels === undefined || pixels > limits.maxPixels) {
|
||||||
|
return {
|
||||||
|
...details,
|
||||||
|
browserRenderable: false,
|
||||||
|
rejectionReason: 'pixel-limit',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...details, browserRenderable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPngDimensions(bytes: Uint8Array): ImageDimensions | undefined {
|
||||||
|
if (bytes.byteLength < PNG_IHDR_SIZE) return undefined;
|
||||||
|
if (
|
||||||
|
readU32Be(bytes, 8) !== 13 ||
|
||||||
|
bytes[12] !== 0x49 ||
|
||||||
|
bytes[13] !== 0x48 ||
|
||||||
|
bytes[14] !== 0x44 ||
|
||||||
|
bytes[15] !== 0x52
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const width = readU32Be(bytes, 16);
|
||||||
|
const height = readU32Be(bytes, 20);
|
||||||
|
const bitDepth = bytes[24]!;
|
||||||
|
const colorType = bytes[25]!;
|
||||||
|
if (
|
||||||
|
width === 0 ||
|
||||||
|
height === 0 ||
|
||||||
|
!validPngBitDepth(bitDepth, colorType) ||
|
||||||
|
bytes[26] !== 0 ||
|
||||||
|
bytes[27] !== 0 ||
|
||||||
|
(bytes[28] !== 0 && bytes[28] !== 1)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPngBitDepth(bitDepth: number, colorType: number): boolean {
|
||||||
|
switch (colorType) {
|
||||||
|
case 0:
|
||||||
|
return [1, 2, 4, 8, 16].includes(bitDepth);
|
||||||
|
case 2:
|
||||||
|
case 4:
|
||||||
|
case 6:
|
||||||
|
return bitDepth === 8 || bitDepth === 16;
|
||||||
|
case 3:
|
||||||
|
return [1, 2, 4, 8].includes(bitDepth);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJpegDimensions(bytes: Uint8Array): ImageDimensions | undefined {
|
||||||
|
const scanEnd = Math.min(bytes.byteLength, JPEG_HEADER_SCAN_BYTES);
|
||||||
|
let offset = 2;
|
||||||
|
let markers = 0;
|
||||||
|
|
||||||
|
while (offset < scanEnd && markers < JPEG_MARKER_LIMIT) {
|
||||||
|
if (bytes[offset] !== 0xff) return undefined;
|
||||||
|
while (offset < scanEnd && bytes[offset] === 0xff) offset += 1;
|
||||||
|
if (offset >= scanEnd) return undefined;
|
||||||
|
const marker = bytes[offset++]!;
|
||||||
|
markers += 1;
|
||||||
|
|
||||||
|
if (marker === 0x00) return undefined;
|
||||||
|
if (marker === 0xd9 || marker === 0xda) return undefined;
|
||||||
|
if (isStandaloneJpegMarker(marker)) continue;
|
||||||
|
if (offset + 2 > scanEnd) return undefined;
|
||||||
|
|
||||||
|
const segmentLength = readU16Be(bytes, offset);
|
||||||
|
if (segmentLength < 2 || offset + segmentLength > scanEnd) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (isStartOfFrame(marker)) {
|
||||||
|
if (segmentLength < 8) return undefined;
|
||||||
|
const height = readU16Be(bytes, offset + 3);
|
||||||
|
const width = readU16Be(bytes, offset + 5);
|
||||||
|
const componentCount = bytes[offset + 7]!;
|
||||||
|
if (
|
||||||
|
width === 0 ||
|
||||||
|
height === 0 ||
|
||||||
|
componentCount === 0 ||
|
||||||
|
segmentLength < 8 + componentCount * 3
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
offset += segmentLength;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandaloneJpegMarker(marker: number): boolean {
|
||||||
|
return (
|
||||||
|
marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStartOfFrame(marker: number): boolean {
|
||||||
|
return (
|
||||||
|
marker >= 0xc0 &&
|
||||||
|
marker <= 0xcf &&
|
||||||
|
marker !== 0xc4 &&
|
||||||
|
marker !== 0xc8 &&
|
||||||
|
marker !== 0xcc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPngSignature(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.byteLength >= PNG_SIGNATURE.byteLength &&
|
||||||
|
PNG_SIGNATURE.every((value, index) => bytes[index] === value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasJpegSignature(bytes: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bytes.byteLength >= 3 &&
|
||||||
|
bytes[0] === 0xff &&
|
||||||
|
bytes[1] === 0xd8 &&
|
||||||
|
bytes[2] === 0xff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU16Be(bytes: Uint8Array, offset: number): number {
|
||||||
|
return bytes[offset]! * 0x100 + bytes[offset + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU32Be(bytes: Uint8Array, offset: number): number {
|
||||||
|
return (
|
||||||
|
bytes[offset]! * 0x100_0000 +
|
||||||
|
bytes[offset + 1]! * 0x1_0000 +
|
||||||
|
bytes[offset + 2]! * 0x100 +
|
||||||
|
bytes[offset + 3]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLimits(limits: BrowserImageSafetyLimits): void {
|
||||||
|
for (const [name, value] of Object.entries(limits)) {
|
||||||
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||||
|
throw new TypeError(`${name} must be a positive safe integer`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,13 @@ export {
|
|||||||
type OneNoteFormatKind,
|
type OneNoteFormatKind,
|
||||||
} from './parser/detect-format.js';
|
} from './parser/detect-format.js';
|
||||||
export { OneNoteParserError } from './parser/error.js';
|
export { OneNoteParserError } from './parser/error.js';
|
||||||
|
export {
|
||||||
|
DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS,
|
||||||
|
inspectBrowserImage,
|
||||||
|
type BrowserImageInspection,
|
||||||
|
type BrowserImageRejectionReason,
|
||||||
|
type BrowserImageSafetyLimits,
|
||||||
|
} from './image-safety.js';
|
||||||
export {
|
export {
|
||||||
DEFAULT_ONENOTE_PARSER_LIMITS,
|
DEFAULT_ONENOTE_PARSER_LIMITS,
|
||||||
type OneNoteParserLimits,
|
type OneNoteParserLimits,
|
||||||
|
|||||||
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 { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
import { readGuid } from '../binary/guid.js';
|
import { readGuid } from '../binary/guid.js';
|
||||||
|
import { inspectBrowserImage } from '../image-safety.js';
|
||||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
import type { OneNoteParserLimits } from '../parser/limits.js';
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -504,7 +505,11 @@ function registerResource(
|
|||||||
const extension = normalizeExtension(
|
const extension = normalizeExtension(
|
||||||
object.fileData?.extension ?? extensionFromFilename(filename)
|
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);
|
const availability = resourceAvailability(object);
|
||||||
context.resources.set(key, {
|
context.resources.set(key, {
|
||||||
id: key,
|
id: key,
|
||||||
@@ -529,6 +534,11 @@ function parseTable(
|
|||||||
): OneNoteTableBlock {
|
): OneNoteTableBlock {
|
||||||
const declaredRowCount = optionalU32(object, PROPERTY.RowCount);
|
const declaredRowCount = optionalU32(object, PROPERTY.RowCount);
|
||||||
const declaredColumnCount = optionalU32(object, PROPERTY.ColumnCount);
|
const declaredColumnCount = optionalU32(object, PROPERTY.ColumnCount);
|
||||||
|
validateDeclaredTableCounts(
|
||||||
|
declaredRowCount,
|
||||||
|
declaredColumnCount,
|
||||||
|
context.limits.maxTableCells
|
||||||
|
);
|
||||||
const rowIds = objectReferences(object, PROPERTY.ElementChildNodes);
|
const rowIds = objectReferences(object, PROPERTY.ElementChildNodes);
|
||||||
if (declaredRowCount !== undefined && declaredRowCount !== rowIds.length) {
|
if (declaredRowCount !== undefined && declaredRowCount !== rowIds.length) {
|
||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
@@ -1014,7 +1024,7 @@ function parseLockedColumns(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const bitmap = bytes.subarray(1, Math.min(bytes.length, byteCount + 1));
|
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(
|
return Array.from(
|
||||||
{ length: count },
|
{ length: count },
|
||||||
(_, index) =>
|
(_, 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(
|
function sniffResource(
|
||||||
blob: FileDataBlobReference | undefined,
|
blob: FileDataBlobReference | undefined,
|
||||||
extension: string | undefined
|
extension: string | undefined,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
): { mediaType: string; browserRenderable: boolean } {
|
): { mediaType: string; browserRenderable: boolean } {
|
||||||
if (blob && blob.size >= 8) {
|
if (blob) {
|
||||||
const bytes = blob.source.subarray(blob.offset, blob.offset + 12);
|
const inspected = inspectBrowserImage(
|
||||||
if (
|
blob.source.subarray(blob.offset, blob.offset + blob.size),
|
||||||
bytes[0] === 0x89 &&
|
{
|
||||||
bytes[1] === 0x50 &&
|
maxBytes: limits.maxAutoRenderImageBytes,
|
||||||
bytes[2] === 0x4e &&
|
maxDimension: limits.maxAutoRenderImageDimension,
|
||||||
bytes[3] === 0x47 &&
|
maxPixels: limits.maxAutoRenderImagePixels,
|
||||||
bytes[4] === 0x0d &&
|
}
|
||||||
bytes[5] === 0x0a &&
|
);
|
||||||
bytes[6] === 0x1a &&
|
if (inspected.mediaType) {
|
||||||
bytes[7] === 0x0a
|
return {
|
||||||
) {
|
mediaType: inspected.mediaType,
|
||||||
return { mediaType: 'image/png', browserRenderable: true };
|
browserRenderable: inspected.browserRenderable,
|
||||||
}
|
};
|
||||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
||||||
return { mediaType: 'image/jpeg', browserRenderable: true };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
|
||||||
|
import { DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS } from '../image-safety.js';
|
||||||
|
|
||||||
export interface OneNoteParserLimits {
|
export interface OneNoteParserLimits {
|
||||||
maxFileBytes: number;
|
maxFileBytes: number;
|
||||||
maxTransactionFragments: number;
|
maxTransactionFragments: number;
|
||||||
@@ -18,6 +20,9 @@ export interface OneNoteParserLimits {
|
|||||||
maxFileDataObjects: number;
|
maxFileDataObjects: number;
|
||||||
maxFileDataBytes: number;
|
maxFileDataBytes: number;
|
||||||
maxTotalFileDataBytes: number;
|
maxTotalFileDataBytes: number;
|
||||||
|
maxAutoRenderImageBytes: number;
|
||||||
|
maxAutoRenderImageDimension: number;
|
||||||
|
maxAutoRenderImagePixels: number;
|
||||||
maxGraphDepth: number;
|
maxGraphDepth: number;
|
||||||
maxContentBlocks: number;
|
maxContentBlocks: number;
|
||||||
maxTextRuns: number;
|
maxTextRuns: number;
|
||||||
@@ -44,6 +49,9 @@ export const DEFAULT_ONENOTE_PARSER_LIMITS: Readonly<OneNoteParserLimits> = {
|
|||||||
maxFileDataObjects: 10_000,
|
maxFileDataObjects: 10_000,
|
||||||
maxFileDataBytes: 256 * 1024 * 1024,
|
maxFileDataBytes: 256 * 1024 * 1024,
|
||||||
maxTotalFileDataBytes: 512 * 1024 * 1024,
|
maxTotalFileDataBytes: 512 * 1024 * 1024,
|
||||||
|
maxAutoRenderImageBytes: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxBytes,
|
||||||
|
maxAutoRenderImageDimension: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxDimension,
|
||||||
|
maxAutoRenderImagePixels: DEFAULT_BROWSER_IMAGE_SAFETY_LIMITS.maxPixels,
|
||||||
maxGraphDepth: 256,
|
maxGraphDepth: 256,
|
||||||
maxContentBlocks: 1_000_000,
|
maxContentBlocks: 1_000_000,
|
||||||
maxTextRuns: 1_000_000,
|
maxTextRuns: 1_000_000,
|
||||||
|
|||||||
Reference in New Issue
Block a user