diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 7d50f0e4..263c3087 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -1,22 +1,101 @@ -import { - Base64SizeError, - dataUriByteLength, -} from '../converter/base64.js'; +import { Base64SizeError } from '../converter/base64.js'; -/** Strict raster-only data-URI form accepted by Inkspan document surfaces. */ -const INLINE_RASTER_SOURCE_PATTERN = - /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/i; +/** Bounded raster data-URI prefix recognized before payload validation. */ +const INLINE_RASTER_SOURCE_PREFIX_PATTERN = + /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,/i; + +/** One canonical base64 payload code unit; padding is handled separately. */ +const BASE64_PAYLOAD_CODE_UNIT_PATTERN = /^[A-Za-z0-9+/]$/; + +/** Valid final sextets before `==`; their four unused low bits are zero. */ +const BASE64_DOUBLE_PADDING_FINAL_CODE_UNIT_PATTERN = /^[AQgw]$/; + +/** Valid final sextets before `=`; their two unused low bits are zero. */ +const BASE64_SINGLE_PADDING_FINAL_CODE_UNIT_PATTERN = /^[AEIMQUYcgkosw048]$/; + +/** Fixed public categories that reveal no caller-defined scheme label. */ +const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN = + /^(?:data|https?|blob|file|javascript)$/i; + +/** Maximum untrusted prefix inspected while classifying source metadata. */ +const IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS = 64; /** Return a bounded, payload-free category for an untrusted image source. */ function redactImageSource(source: unknown): string { if (typeof source !== 'string') return `<${typeof source}>`; if (source.length === 0) return ''; if (source.startsWith('//')) return '//'; - const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(source)?.[1]; - if (scheme) return `${scheme.toLowerCase()}:`; + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec( + source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS), + )?.[1]; + if (scheme) { + if (PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN.test(scheme)) { + return `${scheme.toLowerCase()}:`; + } + return ''; + } return ''; } +/** Return decoded bytes for a source whose strict base64 shape is known. */ +function inlineRasterByteLength(source: string, payloadOffset: number): number { + const payloadLength = source.length - payloadOffset; + const padding = source.endsWith('==') ? 2 : Number(source.endsWith('=')); + return (payloadLength / 4) * 3 - padding; +} + +/** + * Validate the strict raster/base64 grammar without decoding or whole-source regex work. + * + * The MIME/prefix regex sees only a bounded prefix. Payload code units are then + * inspected incrementally so malformed-source precedence remains authoritative + * even for oversized candidates. Canonical padding is inferred only from the + * final one or two code units; any earlier `=` is rejected by the payload scan, + * and unused bits in the final data sextet must be zero. + */ +function strictInlineRasterPayloadOffset(source: string): number | null { + const prefixMatch = INLINE_RASTER_SOURCE_PREFIX_PATTERN.exec( + source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS), + ); + if (!prefixMatch) return null; + + const payloadOffset = prefixMatch[0].length; + const payloadLength = source.length - payloadOffset; + if (payloadLength < 4 || payloadLength % 4 !== 0) return null; + + const padding = source.endsWith('==') ? 2 : Number(source.endsWith('=')); + const payloadDataEnd = source.length - padding; + for (let index = payloadOffset; index < payloadDataEnd; index += 1) { + if (!BASE64_PAYLOAD_CODE_UNIT_PATTERN.test(source.charAt(index))) { + return null; + } + } + + const finalDataCodeUnit = source.charAt(payloadDataEnd - 1); + if ( + padding === 2 && + !BASE64_DOUBLE_PADDING_FINAL_CODE_UNIT_PATTERN.test(finalDataCodeUnit) + ) { + return null; + } + if ( + padding === 1 && + !BASE64_SINGLE_PADDING_FINAL_CODE_UNIT_PATTERN.test(finalDataCodeUnit) + ) { + return null; + } + return payloadOffset; +} + +/** Reject malformed public byte ceilings without coercion or intent inference. */ +function assertValidInlineImageByteLimit(maxSizeBytes: number): void { + if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) { + throw new RangeError( + 'inline image byte limit must be a non-negative safe integer', + ); + } +} + /** Error thrown when an image source violates Inkspan's inline raster policy. */ export class Base64ImageSourceError extends Error { /** Redacted source category safe for logs and host telemetry. */ @@ -43,15 +122,18 @@ export function validateInlineImageSource( source: unknown, maxSizeBytes: number, ): string { - if ( - typeof source !== 'string' || - source.length === 0 || - !INLINE_RASTER_SOURCE_PATTERN.test(source) - ) { + assertValidInlineImageByteLimit(maxSizeBytes); + if (typeof source !== 'string' || source.length === 0) { throw new Base64ImageSourceError(source); } + + const payloadOffset = strictInlineRasterPayloadOffset(source); + if (payloadOffset === null) { + throw new Base64ImageSourceError(source); + } + if (maxSizeBytes > 0) { - const bytes = dataUriByteLength(source); + const bytes = inlineRasterByteLength(source, payloadOffset); if (bytes > maxSizeBytes) { throw new Base64SizeError(bytes, maxSizeBytes); } diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts new file mode 100644 index 00000000..e4c55f12 --- /dev/null +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Base64SizeError } from '../converter/base64.js'; +import { + Base64ImageSourceError, + validateInlineImageSource, +} from './inlineImagePolicy.js'; + +const OVERSIZED_IMAGE = 'data:image/png;base64,QUJDRA=='; + +describe('inline image decoded-size preflight', () => { + it('rejects an oversized valid image without decoding its base64 payload', () => { + const decodeSpy = vi.spyOn(globalThis.Buffer, 'from'); + + try { + expect(() => validateInlineImageSource(OVERSIZED_IMAGE, 3)).toThrowError( + expect.objectContaining({ + name: 'Base64SizeError', + bytes: 4, + maxBytes: 3, + } satisfies Partial), + ); + expect( + decodeSpy.mock.calls.some((call) => { + const args = call as unknown as readonly unknown[]; + return args[0] === 'QUJDRA==' && args[1] === 'base64'; + }), + ).toBe(false); + } finally { + decodeSpy.mockRestore(); + } + }); + + it.each([Number.NaN, -1, 1.5, Number.POSITIVE_INFINITY])( + 'rejects malformed public byte limit %s instead of weakening the resource policy', + (maxSizeBytes) => { + expect(() => validateInlineImageSource(OVERSIZED_IMAGE, maxSizeBytes)).toThrowError( + new RangeError('inline image byte limit must be a non-negative safe integer'), + ); + }, + ); + + it('rejects an unusable byte limit before scanning caller-controlled image source text', () => { + const source = 'data:image/png;base64,QUJDRA=='; + const regexpTestSpy = vi.spyOn(RegExp.prototype, 'test'); + + try { + expect(() => validateInlineImageSource(source, Number.NaN)).toThrowError( + new RangeError('inline image byte limit must be a non-negative safe integer'), + ); + expect( + regexpTestSpy.mock.calls.some((call) => call[0] === source), + ).toBe(false); + } finally { + regexpTestSpy.mockRestore(); + } + }); + + it('rejects a provably oversized valid raster source before full-payload regex scanning', () => { + const source = `data:image/png;base64,${'QUJD'.repeat(16_384)}`; + const regexpTestSpy = vi.spyOn(RegExp.prototype, 'test'); + + try { + expect(() => validateInlineImageSource(source, 3)).toThrowError( + expect.objectContaining({ + name: 'Base64SizeError', + maxBytes: 3, + } satisfies Partial), + ); + expect( + regexpTestSpy.mock.calls.some((call) => call[0] === source), + ).toBe(false); + } finally { + regexpTestSpy.mockRestore(); + } + }); + + it('preserves malformed-source precedence even when the candidate is oversized', () => { + const source = `data:image/png;base64,${'QUJD'.repeat(16_383)}QU*D`; + + expect(() => validateInlineImageSource(source, 3)).toThrow( + Base64ImageSourceError, + ); + }); + + it.each([ + 'data:image/png;base64,AR==', + 'data:image/png;base64,AQJ=', + ])('rejects non-canonical base64 padding bits in %s', (source) => { + expect(() => validateInlineImageSource(source, 0)).toThrow( + Base64ImageSourceError, + ); + }); + + it.each([ + 'https://example.invalid/image.png', + 'data:image/png;base64,', + 'data:image/png;base64,AAA', + 'data:image/png;base64,AA*A', + ])('defers in-bound malformed candidate %s to the strict source grammar', (source) => { + expect(() => validateInlineImageSource(source, 4)).toThrow( + Base64ImageSourceError, + ); + }); + + it('accounts for a single canonical padding byte in an in-bound valid source', () => { + const source = 'data:image/png;base64,QUJDRAA='; + + expect(validateInlineImageSource(source, 5)).toBe(source); + expect(() => validateInlineImageSource(source, 4)).toThrowError( + expect.objectContaining({ + name: 'Base64SizeError', + bytes: 5, + maxBytes: 4, + } satisfies Partial), + ); + }); + + it('does not reflect a caller-controlled custom URI scheme in diagnostics', () => { + const privateMarker = 'privatetenant42'; + const error = new Base64ImageSourceError(`${privateMarker}:opaque`); + + expect(error.sourcePreview).toBe(''); + expect(error.message).not.toContain(privateMarker); + }); + + it('bounds diagnostic scheme inspection before regex work on untrusted source text', () => { + const source = 'a'.repeat(65_536); + const regexpExecSpy = vi.spyOn(RegExp.prototype, 'exec'); + let inspectedLengths: number[] = []; + + try { + const error = new Base64ImageSourceError(source); + inspectedLengths = regexpExecSpy.mock.calls + .map((call) => call[0]) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.length); + expect(error.sourcePreview).toBe(''); + } finally { + regexpExecSpy.mockRestore(); + } + + expect(Math.max(...inspectedLengths)).toBeLessThanOrEqual(64); + }); +});