fix(memory): retune image and orca.yaml ceilings that rejected valid input (#10815)
This commit is contained in:
parent
380034edf9
commit
de162c632b
|
|
@ -39,12 +39,17 @@ describe('buildImageDataUri', () => {
|
|||
expect(buildImageDataUri('application/octet-stream', 'AAAA')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed and oversized known rasters before native decode', () => {
|
||||
expect(buildImageDataUri('image/png', 'bmV3')).toBeNull()
|
||||
it('rejects oversized known rasters before native decode', () => {
|
||||
expect(buildImageDataUri('image/png', pngBase64(32_769, 1))).toBeNull()
|
||||
expect(buildImageDataUri('image/png', pngBase64(8192, 8192))).toBeNull()
|
||||
})
|
||||
|
||||
it('still renders bytes whose header we cannot measure', () => {
|
||||
// Failing to read a header means we could not measure the image, not that it is oversized.
|
||||
// The decoder handles encodings this parser does not, so suppressing here blanks valid images.
|
||||
expect(buildImageDataUri('image/png', 'bmV3')).toBe('data:image/png;base64,bmV3')
|
||||
})
|
||||
|
||||
it('preserves SVG behavior because vectors do not have encoded raster dimensions', () => {
|
||||
expect(buildImageDataUri('image/svg+xml', 'PHN2Zy8+')).toBe(
|
||||
'data:image/svg+xml;base64,PHN2Zy8+'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { readRasterImagePreviewDimensionsFromBase64 } from './raster-image-base64-preview'
|
||||
import { exceedsRasterImagePreviewLimits } from './raster-image-base64-preview'
|
||||
import { isKnownRasterImageMimeType } from './raster-image-preview-limits'
|
||||
|
||||
// Builds an inline `data:` URI for base64 image bytes, shared by the desktop
|
||||
|
|
@ -19,7 +19,9 @@ export function buildImageDataUri(
|
|||
if (!cleaned) {
|
||||
return null
|
||||
}
|
||||
if (readRasterImagePreviewDimensionsFromBase64(cleaned, mimeType) === null) {
|
||||
// Only suppress when the header says the image is too large to render safely. An unreadable
|
||||
// header is not evidence of an oversized image, and the decoder handles formats we cannot parse.
|
||||
if (exceedsRasterImagePreviewLimits(cleaned, mimeType)) {
|
||||
return null
|
||||
}
|
||||
return `data:${mimeType};base64,${cleaned}`
|
||||
|
|
|
|||
|
|
@ -12,16 +12,36 @@ scripts:
|
|||
).toMatchObject({ scripts: { setup: 'pnpm install' } })
|
||||
})
|
||||
|
||||
it('rejects alias expansion beyond the explicit conversion cap', () => {
|
||||
const aliases = Array.from({ length: 21 }, () => '*items').join(', ')
|
||||
it('keeps reusing one anchor across a realistic tab list', () => {
|
||||
// Flat reuse costs anchor size x uses, which the file-size limit already bounds. Rejecting it
|
||||
// broke ordinary configs that merge shared defaults into every tab.
|
||||
const tabs = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) => ` - <<: *shared\n title: tab${index}`
|
||||
).join('\n')
|
||||
|
||||
expect(
|
||||
parseOrcaYaml(`
|
||||
items: &items [one, two]
|
||||
expanded: [${aliases}]
|
||||
scripts:
|
||||
setup: pnpm install
|
||||
shared: &shared
|
||||
command: pnpm dev
|
||||
defaultTabs:
|
||||
${tabs}
|
||||
`)
|
||||
).toBeNull()
|
||||
).toMatchObject({
|
||||
defaultTabs: expect.arrayContaining([{ title: 'tab39', command: 'pnpm dev' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects nested aliases that expand exponentially', () => {
|
||||
// The parser rejects on uses x subtree-alias-count, so depth is what it catches: this is a few
|
||||
// hundred bytes of source that would otherwise materialize millions of nodes.
|
||||
let source = 'a0: &a0 [x, x, x, x, x, x, x, x, x]\n'
|
||||
for (let level = 1; level <= 8; level += 1) {
|
||||
source += `a${level}: &a${level} [${Array(9)
|
||||
.fill(`*a${level - 1}`)
|
||||
.join(', ')}]\n`
|
||||
}
|
||||
|
||||
expect(parseOrcaYaml(`${source}scripts:\n setup: *a8\n`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ export const MAX_ORCA_YAML_CODE_UNITS = 256 * 1024
|
|||
export const MAX_ORCA_YAML_FIELD_BYTES = 64 * 1024
|
||||
export const MAX_ORCA_YAML_FIELD_CODE_UNITS = 64 * 1024
|
||||
export const MAX_ORCA_YAML_COLLECTION_ENTRIES = 256
|
||||
export const MAX_ORCA_YAML_ALIAS_COUNT = 20
|
||||
// The yaml parser rejects on `useCount * subtreeAliasCount`, so exponential expansion is caught by
|
||||
// the multiplication regardless of this value; lowering it only rejects flat, linear reuse. Keep the
|
||||
// library default so an orca.yaml that merges one anchor into many tabs still parses.
|
||||
export const MAX_ORCA_YAML_ALIAS_COUNT = 100
|
||||
|
||||
export function isOrcaYamlTextWithinLimit(content: string): boolean {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { RasterImageDimensions } from './raster-image-dimensions'
|
||||
import { readRasterImageDimensions } from './raster-image-dimensions'
|
||||
import {
|
||||
assertRasterImagePreviewWithinLimits,
|
||||
isKnownRasterImageMimeType,
|
||||
isRasterImagePreviewDimensions,
|
||||
RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES
|
||||
} from './raster-image-preview-limits'
|
||||
|
||||
|
|
@ -121,21 +121,24 @@ function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | nul
|
|||
return output.subarray(0, outputLength)
|
||||
}
|
||||
|
||||
/** Returns undefined for non-raster MIME types and null for rejected raster bytes. */
|
||||
export function readRasterImagePreviewDimensionsFromBase64(
|
||||
/**
|
||||
* Whether the encoded dimensions are known to exceed the preview limits.
|
||||
*
|
||||
* Distinct from a failed read: an unrecognized or truncated header means we could not measure the
|
||||
* image, not that it is too large. Treating those the same blanks out valid images that no decoder
|
||||
* has trouble with, so only a confident over-limit answer should suppress a preview.
|
||||
*/
|
||||
export function exceedsRasterImagePreviewLimits(
|
||||
content: string,
|
||||
mimeType: string | undefined
|
||||
): RasterImageDimensions | null | undefined {
|
||||
): boolean {
|
||||
if (!isKnownRasterImageMimeType(mimeType)) {
|
||||
return undefined
|
||||
return false
|
||||
}
|
||||
const prefix = decodeBase64Prefix(content, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES)
|
||||
if (!prefix) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return assertRasterImagePreviewWithinLimits(prefix, mimeType) ?? null
|
||||
} catch {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
const dimensions = readRasterImageDimensions(prefix)
|
||||
return dimensions !== null && !isRasterImagePreviewDimensions(dimensions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,39 @@ function bmpHeader(width: number, height: number): Buffer {
|
|||
return bmp
|
||||
}
|
||||
|
||||
/** SOI, then `metadataBytes` of APP2 padding (plus `extraSegments` empty ones), then SOF0. */
|
||||
function jpegWithMetadata(
|
||||
metadataBytes: number,
|
||||
width: number,
|
||||
height: number,
|
||||
extraSegments = 0
|
||||
): Buffer {
|
||||
const parts = [Buffer.from([0xff, 0xd8])]
|
||||
for (let written = 0; written < metadataBytes; ) {
|
||||
// A JPEG segment length field is 16 bits, so real files chain many segments to carry a profile.
|
||||
const size = Math.min(65_533, metadataBytes - written)
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt16BE(0xffe2)
|
||||
header.writeUInt16BE(size + 2, 2)
|
||||
parts.push(header, Buffer.alloc(size))
|
||||
written += size
|
||||
}
|
||||
for (let index = 0; index < extraSegments; index += 1) {
|
||||
const empty = Buffer.alloc(4)
|
||||
empty.writeUInt16BE(0xffe2)
|
||||
empty.writeUInt16BE(2, 2)
|
||||
parts.push(empty)
|
||||
}
|
||||
const sof = Buffer.alloc(11)
|
||||
sof.writeUInt16BE(0xffc0)
|
||||
sof.writeUInt16BE(8, 2)
|
||||
sof[4] = 8
|
||||
sof.writeUInt16BE(height, 5)
|
||||
sof.writeUInt16BE(width, 7)
|
||||
parts.push(sof)
|
||||
return Buffer.concat(parts)
|
||||
}
|
||||
|
||||
function icoWithPayload(payload: Buffer, width = 1, height = 1): Buffer {
|
||||
const header = Buffer.alloc(22)
|
||||
header.writeUInt16LE(1, 2)
|
||||
|
|
@ -59,4 +92,17 @@ describe('readRasterImageDimensions', () => {
|
|||
expect(readRasterImageDimensions(truncated)).toBeNull()
|
||||
expect(readRasterImageDimensions(bmpHeader(0, 16))).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a JPEG whose frame header sits behind large chained metadata', () => {
|
||||
// Cameras and editors emit ICC/MPF profiles split across many 64 KiB segments, pushing SOF0 far
|
||||
// into the file. Both shapes below decode everywhere, so neither may read as an unknown size.
|
||||
expect(readRasterImageDimensions(jpegWithMetadata(1_400 * 1024, 4_000, 3_000))).toEqual({
|
||||
width: 4_000,
|
||||
height: 3_000
|
||||
})
|
||||
expect(readRasterImageDimensions(jpegWithMetadata(0, 640, 480, 6_000))).toEqual({
|
||||
width: 640,
|
||||
height: 480
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export type RasterImageDimensions = { width: number; height: number }
|
||||
|
||||
const JPEG_DIMENSION_SCAN_MAX_BYTES = 1024 * 1024
|
||||
const JPEG_DIMENSION_SCAN_MAX_MARKERS = 4_096
|
||||
// No scan cap: this is a forward seek over a caller-bounded buffer, so it costs O(1) memory and at
|
||||
// most one pass. Capping it only made valid images with large ICC/MPF metadata unreadable.
|
||||
const ICO_MAX_IMAGES = 1_024
|
||||
const JPEG_START_OF_FRAME_MARKERS = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
|
||||
|
|
@ -100,15 +100,13 @@ function readJpegDimensions(bytes: Uint8Array): RasterImageDimensions | null {
|
|||
return null
|
||||
}
|
||||
let offset = 2
|
||||
let markersRead = 0
|
||||
const scanEnd = Math.min(bytes.byteLength, JPEG_DIMENSION_SCAN_MAX_BYTES)
|
||||
while (offset < scanEnd && markersRead < JPEG_DIMENSION_SCAN_MAX_MARKERS) {
|
||||
const scanEnd = bytes.byteLength
|
||||
while (offset < scanEnd) {
|
||||
while (offset < scanEnd && bytes[offset] === 0xff) {
|
||||
offset += 1
|
||||
}
|
||||
const marker = bytes[offset]
|
||||
offset += 1
|
||||
markersRead += 1
|
||||
if (marker === undefined || marker === 0x00 || marker === 0xd9 || marker === 0xda) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { readRasterImageDimensions, type RasterImageDimensions } from './raster-
|
|||
|
||||
export const MAX_RASTER_IMAGE_PREVIEW_DIMENSION_PX = 32_768
|
||||
export const MAX_RASTER_IMAGE_PREVIEW_PIXELS = 32 * 1024 * 1024
|
||||
export const RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES = 1024 * 1024
|
||||
// Bounds the transient decode buffer, not the image. 1 MiB cut off valid photos whose SOF sits past
|
||||
// a large ICC/MPF block; 8 MiB clears every real-world metadata layout while staying a fraction of
|
||||
// the base64 string the caller already holds.
|
||||
export const RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES = 8 * 1024 * 1024
|
||||
export const INVALID_RASTER_IMAGE_PREVIEW_ERROR =
|
||||
'Image preview has invalid or unsupported raster dimensions'
|
||||
export const RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR =
|
||||
|
|
|
|||
Loading…
Reference in New Issue