From 2b001d6222752911f5a171ff23f101877ee0c684 Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 18:38:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=85[Test]=20Add=20deterministic=20mal?= =?UTF-8?q?formed=20binary=20corpus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/corpus/cases.ts | 410 +++++++++++++++++++ tests/corpus/public-api-invariants.test.ts | 131 ++++++ tests/corpus/tiff-invariants.test.ts | 245 +++++++++++ tests/malformed/deterministic-inputs.test.ts | 24 -- 4 files changed, 786 insertions(+), 24 deletions(-) create mode 100644 tests/corpus/cases.ts create mode 100644 tests/corpus/public-api-invariants.test.ts create mode 100644 tests/corpus/tiff-invariants.test.ts delete mode 100644 tests/malformed/deterministic-inputs.test.ts diff --git a/tests/corpus/cases.ts b/tests/corpus/cases.ts new file mode 100644 index 0000000..f1aea85 --- /dev/null +++ b/tests/corpus/cases.ts @@ -0,0 +1,410 @@ +import type { + ImageFormat, + ParseLimits, + SecureMetadataErrorCode, +} from "../../src/index.js"; +import { + concat as jpegConcat, + EXIF, + jpeg, + marker, + MARKER, + segment, +} from "../helpers/jpeg-builder.js"; +import { + chunk as pngChunk, + concat as pngConcat, + png, + PNG_SIGNATURE, + u32be, +} from "../helpers/png-builder.js"; +import { + chunk as webpChunk, + concat as webpConcat, + fourCC, + u32le, + vp8x, + webp, + withRiffSize, +} from "../helpers/webp-builder.js"; + +export type CorpusCategory = "generic" | "jpeg" | "webp" | "png"; + +export interface MalformedCase { + readonly name: string; + readonly category: CorpusCategory; + readonly input: Uint8Array; + readonly expectedFormat: ImageFormat; + readonly expectedStatus?: + | "format-only" + | "container-partial" + | "container-inspected" + | "metadata-partial"; + readonly expectedDiagnostic?: string; + readonly cleanError?: SecureMetadataErrorCode; + readonly cleanable?: true; + readonly limits?: Partial; +} + +function alternating(length: number): Uint8Array { + return Uint8Array.from({ length }, (_, index) => + index % 2 === 0 ? 0xaa : 0x55, + ); +} + +function jpegDeclaredSegment( + code: number, + declaredLength: number, + payload: Uint8Array = new Uint8Array(), +): Uint8Array { + return jpegConcat( + marker(code), + Uint8Array.of(declaredLength >>> 8, declaredLength & 0xff), + payload, + ); +} + +function rawWebPBody(bodyAfterFormType: Uint8Array): Uint8Array { + const body = webpConcat(fourCC("WEBP"), bodyAfterFormType); + return webpConcat(fourCC("RIFF"), u32le(body.byteLength), body); +} + +const GENERIC_CASES: readonly MalformedCase[] = [ + ["generic-empty", Uint8Array.of()], + ["generic-single-zero", Uint8Array.of(0)], + ["generic-single-ff", Uint8Array.of(0xff)], + ["generic-zeroes", new Uint8Array(16)], + ["generic-ff-fill", new Uint8Array(16).fill(0xff)], + ["generic-alternating-bytes", alternating(16)], + ["generic-jpeg-prefix", Uint8Array.of(0xff)], + ["generic-png-signature-prefix", PNG_SIGNATURE.slice(0, 7)], + [ + "generic-riff-webp-prefix", + webpConcat(fourCC("RIFF"), u32le(4), Uint8Array.of(0x57, 0x45, 0x42)), + ], +].map(([name, input]) => ({ + name: name as string, + category: "generic" as const, + input: input as Uint8Array, + expectedFormat: "unknown" as const, + cleanError: "UNSUPPORTED_FORMAT" as const, +})); + +const JPEG_CASES: readonly MalformedCase[] = [ + { + name: "jpeg-soi-only", + input: marker(MARKER.SOI), + expectedDiagnostic: "JPEG_MISSING_EOI", + }, + { + name: "jpeg-truncated-marker", + input: jpegConcat(marker(MARKER.SOI), Uint8Array.of(0xff)), + expectedDiagnostic: "JPEG_TRUNCATED_MARKER", + }, + { + name: "jpeg-fill-bytes-ending-at-eof", + input: jpegConcat(marker(MARKER.SOI), Uint8Array.of(0xff, 0xff, 0xff)), + expectedDiagnostic: "JPEG_TRUNCATED_MARKER", + }, + { + name: "jpeg-truncated-app1-length", + input: jpegConcat(marker(MARKER.SOI), marker(MARKER.APP1)), + expectedDiagnostic: "JPEG_TRUNCATED_SEGMENT_LENGTH", + }, + { + name: "jpeg-invalid-segment-length-one", + input: jpegConcat(marker(MARKER.SOI), jpegDeclaredSegment(MARKER.APP1, 1)), + expectedDiagnostic: "JPEG_INVALID_SEGMENT_LENGTH", + }, + { + name: "jpeg-declared-segment-past-eof", + input: jpegConcat( + marker(MARKER.SOI), + jpegDeclaredSegment(MARKER.APP1, 8, Uint8Array.of(0x45)), + ), + expectedDiagnostic: "JPEG_TRUNCATED_SEGMENT", + }, + { + name: "jpeg-truncated-sos-header", + input: jpegConcat(marker(MARKER.SOI), marker(MARKER.SOS), Uint8Array.of(0)), + expectedDiagnostic: "JPEG_TRUNCATED_SEGMENT_LENGTH", + }, + { + name: "jpeg-scan-ending-with-ff", + input: jpegConcat( + marker(MARKER.SOI), + segment(MARKER.SOS), + Uint8Array.of(1, 0xff), + ), + expectedDiagnostic: "JPEG_TRUNCATED_SCAN", + }, + { + name: "jpeg-stuffed-ff00-near-eof", + input: jpegConcat( + marker(MARKER.SOI), + segment(MARKER.SOS), + Uint8Array.of(1, 0xff, 0, 2), + ), + expectedDiagnostic: "JPEG_TRUNCATED_SCAN", + }, + { + name: "jpeg-restart-marker-near-eof", + input: jpegConcat( + marker(MARKER.SOI), + segment(MARKER.SOS), + Uint8Array.of(1, 0xff, 0xd0), + ), + expectedDiagnostic: "JPEG_TRUNCATED_SCAN", + }, + { + name: "jpeg-missing-eoi", + input: jpegConcat(marker(MARKER.SOI), segment(MARKER.APP1, EXIF)), + expectedDiagnostic: "JPEG_MISSING_EOI", + }, + { + name: "jpeg-second-scan-truncated", + input: jpegConcat( + marker(MARKER.SOI), + segment(MARKER.SOS), + Uint8Array.of(1), + segment(MARKER.SOS), + Uint8Array.of(2, 0xff), + ), + expectedDiagnostic: "JPEG_TRUNCATED_SCAN", + }, + { + name: "jpeg-segment-limit-exceeded", + input: jpeg(segment(MARKER.APP0), segment(MARKER.APP1)), + expectedDiagnostic: "JPEG_SEGMENT_LIMIT_EXCEEDED", + limits: { maxSegments: 2 }, + }, + { + name: "jpeg-bounded-malformed-exif", + input: jpeg(segment(MARKER.APP1, jpegConcat(EXIF, Uint8Array.of(0x49)))), + expectedStatus: "metadata-partial", + expectedDiagnostic: "TIFF_TRUNCATED_HEADER", + cleanable: true, + }, +].map( + (item) => + ({ + category: "jpeg" as const, + expectedFormat: "jpeg" as const, + expectedStatus: "container-partial" as const, + cleanError: "INCOMPLETE_JPEG" as const, + ...item, + }) as MalformedCase, +); + +const WEBP_CASES: readonly MalformedCase[] = [ + { + name: "webp-riff-prefix-only", + input: fourCC("RIFF"), + expectedFormat: "unknown", + expectedStatus: "format-only", + cleanError: "UNSUPPORTED_FORMAT", + }, + { + name: "webp-riff-webp-header-truncated", + input: webpConcat( + fourCC("RIFF"), + u32le(4), + Uint8Array.of(0x57, 0x45, 0x42), + ), + expectedFormat: "unknown", + expectedStatus: "format-only", + cleanError: "UNSUPPORTED_FORMAT", + }, + { + name: "webp-riff-size-below-minimum", + input: withRiffSize(webp([]), 3), + expectedDiagnostic: "WEBP_INVALID_RIFF_SIZE", + }, + { + name: "webp-declared-riff-past-eof", + input: withRiffSize(webp([]), 100), + expectedDiagnostic: "WEBP_TRUNCATED_RIFF", + }, + { + name: "webp-partial-chunk-fourcc", + input: rawWebPBody(Uint8Array.of(0x45, 0x58)), + expectedDiagnostic: "WEBP_TRUNCATED_CHUNK_HEADER", + }, + { + name: "webp-partial-chunk-length", + input: rawWebPBody(webpConcat(fourCC("EXIF"), Uint8Array.of(1, 0))), + expectedDiagnostic: "WEBP_TRUNCATED_CHUNK_HEADER", + }, + { + name: "webp-chunk-payload-past-riff", + input: rawWebPBody(webpConcat(fourCC("EXIF"), u32le(5), Uint8Array.of(1))), + expectedDiagnostic: "WEBP_TRUNCATED_CHUNK", + }, + { + name: "webp-odd-chunk-missing-pad", + input: rawWebPBody(webpConcat(fourCC("XMP "), u32le(1), Uint8Array.of(1))), + expectedDiagnostic: "WEBP_INVALID_PADDING", + }, + { + name: "webp-duplicate-vp8x", + input: webp([vp8x(0), vp8x(0)]), + expectedDiagnostic: "WEBP_DUPLICATE_VP8X", + }, + { + name: "webp-invalid-vp8x-length", + input: webp([webpChunk("VP8X", Uint8Array.of(1))]), + expectedDiagnostic: "WEBP_INVALID_VP8X", + }, + { + name: "webp-inconsistent-vp8x-flags", + input: webp([vp8x(0x08)]), + expectedStatus: "container-inspected", + expectedDiagnostic: "WEBP_INCONSISTENT_FEATURE_FLAGS", + cleanable: true, + }, + { + name: "webp-chunk-limit-exceeded", + input: webp([webpChunk("VP8 "), webpChunk("ANIM")]), + expectedDiagnostic: "WEBP_CHUNK_LIMIT_EXCEEDED", + limits: { maxChunks: 1 }, + }, + { + name: "webp-bounded-malformed-exif", + input: webp([webpChunk("EXIF", Uint8Array.of(0x49))]), + expectedStatus: "container-inspected", + cleanable: true, + }, +].map( + (item) => + ({ + category: "webp" as const, + expectedFormat: "webp" as const, + expectedStatus: "container-partial" as const, + cleanError: "INCOMPLETE_WEBP" as const, + ...item, + }) as MalformedCase, +); + +const PNG_CASES: readonly MalformedCase[] = [ + { + name: "png-signature-only", + input: PNG_SIGNATURE, + expectedDiagnostic: "PNG_MISSING_IEND", + }, + { + name: "png-partial-chunk-length", + input: pngConcat(PNG_SIGNATURE, Uint8Array.of(0, 0)), + expectedDiagnostic: "PNG_TRUNCATED_CHUNK_LENGTH", + }, + { + name: "png-partial-chunk-fourcc", + input: pngConcat(PNG_SIGNATURE, u32be(0), Uint8Array.of(0x49, 0x45)), + expectedDiagnostic: "PNG_TRUNCATED_CHUNK_TYPE", + }, + { + name: "png-chunk-data-truncated", + input: pngConcat( + PNG_SIGNATURE, + u32be(5), + Uint8Array.of(0x49, 0x44, 0x41, 0x54, 1), + ), + expectedDiagnostic: "PNG_TRUNCATED_CHUNK_DATA", + }, + { + name: "png-chunk-crc-truncated", + input: pngConcat( + PNG_SIGNATURE, + u32be(0), + Uint8Array.of(0x49, 0x44, 0x41, 0x54, 0), + ), + expectedDiagnostic: "PNG_MISSING_CRC", + }, + { + name: "png-invalid-retained-crc", + input: png([pngChunk("IDAT", Uint8Array.of(1), 0), pngChunk("IEND")]), + expectedStatus: "container-inspected", + expectedDiagnostic: "PNG_INVALID_CRC", + cleanable: true, + }, + { + name: "png-missing-iend", + input: png([pngChunk("IDAT")]), + expectedDiagnostic: "PNG_MISSING_IEND", + }, + { + name: "png-data-after-iend", + input: png([pngChunk("IEND")], Uint8Array.of(1, 2, 3)), + expectedStatus: "container-inspected", + expectedDiagnostic: "PNG_TRAILING_DATA", + cleanable: true, + }, + { + name: "png-very-large-declared-length", + input: pngConcat( + PNG_SIGNATURE, + u32be(0xffff_ffff), + Uint8Array.of(0x49, 0x44, 0x41, 0x54), + ), + expectedDiagnostic: "PNG_TRUNCATED_CHUNK_DATA", + }, + { + name: "png-chunk-limit-exceeded", + input: png([pngChunk("IDAT"), pngChunk("IEND")]), + expectedDiagnostic: "PNG_CHUNK_LIMIT_EXCEEDED", + limits: { maxChunks: 1 }, + }, + { + name: "png-malformed-text-structure", + input: png([pngChunk("tEXt", Uint8Array.of(0)), pngChunk("IEND")]), + expectedStatus: "container-inspected", + expectedDiagnostic: "PNG_INVALID_TEXT", + cleanable: true, + }, + { + name: "png-malformed-ztxt-prefix", + input: png([pngChunk("zTXt", Uint8Array.of(0x4b, 0)), pngChunk("IEND")]), + expectedStatus: "container-inspected", + expectedDiagnostic: "PNG_INVALID_TEXT", + cleanable: true, + }, + { + name: "png-malformed-itxt-prefix", + input: png([ + pngChunk("iTXt", Uint8Array.of(0x4b, 0, 2, 0)), + pngChunk("IEND"), + ]), + expectedStatus: "container-inspected", + expectedDiagnostic: "PNG_INVALID_TEXT", + cleanable: true, + }, + { + name: "png-bounded-malformed-exif", + input: png([pngChunk("eXIf", Uint8Array.of(0x49)), pngChunk("IEND")]), + expectedStatus: "metadata-partial", + expectedDiagnostic: "TIFF_TRUNCATED_HEADER", + cleanable: true, + }, +].map( + (item) => + ({ + category: "png" as const, + expectedFormat: "png" as const, + expectedStatus: "container-partial" as const, + cleanError: "INCOMPLETE_PNG" as const, + ...item, + }) as MalformedCase, +); + +export const MALFORMED_CORPUS: readonly MalformedCase[] = [ + ...GENERIC_CASES, + ...JPEG_CASES, + ...WEBP_CASES, + ...PNG_CASES, +]; + +export const CORPUS_COUNTS = Object.freeze({ + generic: GENERIC_CASES.length, + jpeg: JPEG_CASES.length, + webp: WEBP_CASES.length, + png: PNG_CASES.length, +}); diff --git a/tests/corpus/public-api-invariants.test.ts b/tests/corpus/public-api-invariants.test.ts new file mode 100644 index 0000000..c974953 --- /dev/null +++ b/tests/corpus/public-api-invariants.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { + cleanMetadata, + inspectMetadata, + SecureMetadataError, + verifyMetadata, +} from "../../src/index.js"; +import { chunk, png } from "../helpers/png-builder.js"; +import { CORPUS_COUNTS, MALFORMED_CORPUS } from "./cases.js"; + +function captureError(operation: () => unknown): unknown { + try { + return operation(); + } catch (error) { + return error; + } +} + +describe("deterministic malformed corpus", () => { + it("covers named generic and container corruption families", () => { + expect(CORPUS_COUNTS).toEqual({ generic: 9, jpeg: 14, webp: 13, png: 14 }); + expect(new Set(MALFORMED_CORPUS.map(({ name }) => name)).size).toBe( + MALFORMED_CORPUS.length, + ); + }); + + it("inspects deterministically without native exceptions or input mutation", () => { + for (const testCase of MALFORMED_CORPUS) { + const before = Uint8Array.from(testCase.input); + const configuredLimits = + testCase.limits === undefined ? undefined : { limits: testCase.limits }; + const first = captureError(() => + inspectMetadata(testCase.input, configuredLimits), + ); + const second = captureError(() => + inspectMetadata(testCase.input, configuredLimits), + ); + + expect(first, testCase.name).not.toBeInstanceOf(RangeError); + expect(first, testCase.name).not.toBeInstanceOf(TypeError); + expect(first, testCase.name).toEqual(second); + expect(first, testCase.name).toMatchObject({ + format: testCase.expectedFormat, + ...(testCase.expectedStatus === undefined + ? {} + : { inspectionStatus: testCase.expectedStatus }), + }); + if (testCase.expectedDiagnostic !== undefined) { + expect(first, testCase.name).toMatchObject({ + diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: testCase.expectedDiagnostic }), + ]), + }); + } + expect(testCase.input, testCase.name).toEqual(before); + } + }); + + it("fails closed before output or reconstructs a complete deterministic result", () => { + for (const testCase of MALFORMED_CORPUS) { + const before = Uint8Array.from(testCase.input); + const configuredLimits = + testCase.limits === undefined ? undefined : { limits: testCase.limits }; + if (testCase.cleanable === true) { + const first = cleanMetadata(testCase.input, configuredLimits); + const second = cleanMetadata(testCase.input, configuredLimits); + + expect(first.output, testCase.name).toEqual(second.output); + expect(first.output.byteLength, testCase.name).toBeLessThanOrEqual( + testCase.input.byteLength, + ); + expect(first.report.inspectionStatus, testCase.name).not.toBe( + "container-partial", + ); + expect(verifyMetadata(first.output).valid, testCase.name).toBe(true); + } else { + const error = captureError(() => + cleanMetadata(testCase.input, configuredLimits), + ); + expect(error, testCase.name).toBeInstanceOf(SecureMetadataError); + expect(error, testCase.name).toMatchObject({ + code: testCase.cleanError, + }); + + const verifyError = captureError(() => + verifyMetadata(testCase.input, configuredLimits), + ); + expect(verifyError, testCase.name).toBeInstanceOf(SecureMetadataError); + expect(verifyError, testCase.name).toMatchObject({ + code: testCase.cleanError, + }); + } + expect(testCase.input, testCase.name).toEqual(before); + } + }); + + it("preserves invalid CRCs in retained PNG chunks instead of repairing them", () => { + const testCase = MALFORMED_CORPUS.find( + ({ name }) => name === "png-invalid-retained-crc", + ); + expect(testCase).toBeDefined(); + if (testCase === undefined) { + throw new Error("PNG invalid-CRC corpus case is missing."); + } + + const result = cleanMetadata(testCase.input); + expect(result.output).toEqual(testCase.input); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "PNG_INVALID_CRC" }), + ); + }); +}); + +describe("cheap malformed-input limit stress", () => { + it.each([0, 1, 2] as const)( + "caps diagnostics at maxDiagnostics %i without changing completeness", + (maxDiagnostics) => { + const input = png([ + chunk("IDAT", Uint8Array.of(1), 0), + chunk("IDAT", Uint8Array.of(2), 0), + chunk("IDAT", Uint8Array.of(3), 0), + chunk("IEND"), + ]); + const report = inspectMetadata(input, { limits: { maxDiagnostics } }); + + expect(report.inspectionStatus).toBe("container-inspected"); + expect(report.diagnostics).toHaveLength(maxDiagnostics); + }, + ); +}); diff --git a/tests/corpus/tiff-invariants.test.ts b/tests/corpus/tiff-invariants.test.ts new file mode 100644 index 0000000..3efdcb3 --- /dev/null +++ b/tests/corpus/tiff-invariants.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; + +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { parseTiff, type TiffParseLimits } from "../../src/exif/tiff.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; + +const LIMITS: TiffParseLimits = { + maxIfdEntries: 32, + maxIfdDepth: 8, + maxMetadataEntries: 64, + maxStringBytes: 1_024, + maxDiagnostics: 8, +}; + +interface TiffCorpusCase { + readonly name: string; + readonly input: Uint8Array; + readonly diagnostic?: string; + readonly limits?: Partial; + readonly expectedEntries?: number; +} + +function changed( + input: Uint8Array, + offset: number, + ...values: number[] +): Uint8Array { + const output = Uint8Array.from(input); + output.set(values, offset); + return output; +} + +function pointer(tag: number, target: number): Uint8Array { + return new TiffBuilder() + .ifd(8, [ + { + tag, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: target, + }, + ]) + .finish(); +} + +function exposure(denominator: number): Uint8Array { + return new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 40, + }, + ]) + .ifd(40, [ + { + tag: TIFF_TAG.EXPOSURE_TIME, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 1, + valueOffset: 100, + }, + ]) + .rational(100, [[1, denominator]]) + .finish(); +} + +const TIFF_CORPUS: readonly TiffCorpusCase[] = [ + { + name: "tiff-truncated-header", + input: Uint8Array.of(0x49, 0x49, 42), + diagnostic: "TIFF_TRUNCATED_HEADER", + }, + { + name: "tiff-invalid-byte-order", + input: Uint8Array.of(0x58, 0x58, 42, 0, 8, 0, 0, 0), + diagnostic: "TIFF_INVALID_BYTE_ORDER", + }, + { + name: "tiff-invalid-magic", + input: changed(new TiffBuilder().ifd(8, []).finish(), 2, 41, 0), + diagnostic: "TIFF_INVALID_MAGIC", + }, + { + name: "tiff-first-ifd-offset-out-of-bounds", + input: new TiffBuilder("little", 1_024, 900).finish(), + diagnostic: "TIFF_INVALID_FIRST_IFD_OFFSET", + }, + { + name: "tiff-entry-count-over-limit", + input: new TiffBuilder() + .ifd(8, [ + { tag: 1, type: TIFF_FIELD_TYPE.BYTE, count: 1, value: 1 }, + { tag: 2, type: TIFF_FIELD_TYPE.BYTE, count: 1, value: 2 }, + ]) + .finish(), + diagnostic: "TIFF_IFD_ENTRY_LIMIT_EXCEEDED", + limits: { maxIfdEntries: 1 }, + }, + { + name: "tiff-truncated-entry-table", + input: new TiffBuilder().u16(8, 1).finish(10), + diagnostic: "TIFF_TRUNCATED_IFD", + }, + { + name: "tiff-unsupported-field-type", + input: new TiffBuilder() + .ifd(8, [{ tag: TIFF_TAG.MAKE, type: 99, count: 1, value: 0 }]) + .finish(), + diagnostic: "TIFF_UNSUPPORTED_FIELD_TYPE", + expectedEntries: 1, + }, + { + name: "tiff-huge-value-count", + input: new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 0xffff_ffff, + valueOffset: 100, + }, + ]) + .finish(), + diagnostic: "TIFF_INVALID_VALUE_RANGE", + }, + { + name: "tiff-invalid-external-value-offset", + input: new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 900, + }, + ]) + .finish(), + diagnostic: "TIFF_INVALID_VALUE_OFFSET", + }, + { + name: "tiff-exif-ifd-pointer-out-of-range", + input: pointer(TIFF_TAG.EXIF_IFD_POINTER, 900), + diagnostic: "TIFF_INVALID_POINTER", + }, + { + name: "tiff-gps-ifd-pointer-out-of-range", + input: pointer(TIFF_TAG.GPS_IFD_POINTER, 900), + diagnostic: "TIFF_INVALID_POINTER", + }, + { + name: "tiff-self-referencing-ifd", + input: new TiffBuilder().ifd(8, [], 8).finish(), + diagnostic: "TIFF_CYCLIC_IFD", + }, + { + name: "tiff-two-node-ifd-cycle", + input: new TiffBuilder().ifd(8, [], 40).ifd(40, [], 8).finish(), + diagnostic: "TIFF_CYCLIC_IFD", + }, + { + name: "tiff-ifd-depth-chain-over-limit", + input: new TiffBuilder() + .ifd(8, [], 40) + .ifd(40, [], 80) + .ifd(80, []) + .finish(), + diagnostic: "TIFF_IFD_DEPTH_LIMIT_EXCEEDED", + limits: { maxIfdDepth: 2 }, + }, + { + name: "tiff-zero-rational-denominator", + input: exposure(0), + diagnostic: "TIFF_INVALID_RATIONAL", + expectedEntries: 1, + }, + { + name: "tiff-duplicate-tags", + input: new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 100, + }, + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 110, + }, + ]) + .ascii(100, "Canon") + .ascii(110, "Nikon") + .finish(), + expectedEntries: 2, + }, + { + name: "tiff-ascii-string-limit", + input: new TiffBuilder() + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: 6, + valueOffset: 100, + }, + ]) + .ascii(100, "Canon") + .finish(), + diagnostic: "TIFF_INVALID_VALUE_RANGE", + limits: { maxStringBytes: 4 }, + }, +]; + +describe("bounded TIFF malformed corpus", () => { + it("covers the shared TIFF corruption and traversal families", () => { + expect(TIFF_CORPUS).toHaveLength(17); + expect(new Set(TIFF_CORPUS.map(({ name }) => name)).size).toBe(17); + }); + + it("terminates deterministically without native exceptions or mutation", () => { + for (const testCase of TIFF_CORPUS) { + const before = Uint8Array.from(testCase.input); + const limits = { ...LIMITS, ...testCase.limits }; + const first = parseTiff(testCase.input, limits); + const second = parseTiff(testCase.input, limits); + + expect(first, testCase.name).toEqual(second); + expect(testCase.input, testCase.name).toEqual(before); + if (testCase.diagnostic !== undefined) { + expect(first.diagnostics, testCase.name).toContainEqual( + expect.objectContaining({ code: testCase.diagnostic }), + ); + } + if (testCase.expectedEntries !== undefined) { + expect(first.entries, testCase.name).toHaveLength( + testCase.expectedEntries, + ); + } + } + }); +}); diff --git a/tests/malformed/deterministic-inputs.test.ts b/tests/malformed/deterministic-inputs.test.ts deleted file mode 100644 index 049f9c2..0000000 --- a/tests/malformed/deterministic-inputs.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { inspectMetadata } from "../../src/index.js"; - -const MALFORMED_INPUTS = [ - Uint8Array.of(), - Uint8Array.of(0x00), - Uint8Array.of(0xff), - Uint8Array.of(0xff, 0xd8), - Uint8Array.of(0x01, 0x02, 0x03), - new Uint8Array(16), - new Uint8Array(16).fill(0xff), - Uint8Array.of(0x52, 0x49, 0x46, 0x46, 0x00), - Uint8Array.of(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57), - Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a), - Uint8Array.of(0x89, 0x50, 0x00, 0x47), -] as const; - -describe("deterministic malformed inputs", () => { - it.each(MALFORMED_INPUTS)("is ordinary inspection input: %j", (input) => { - expect(() => inspectMetadata(input)).not.toThrow(); - expect(inspectMetadata(input)).toEqual(inspectMetadata(input)); - }); -}); From 2977cb0fc899b52dac17d98d540241ef3742787a Mon Sep 17 00:00:00 2001 From: maruson08 Date: Tue, 25 Aug 2026 18:39:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=84[Docs]=20Document=20fuzz=20and?= =?UTF-8?q?=20corpus=20testing=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ README.md | 2 +- docs/architecture.md | 13 +++++++++++ docs/security-model.md | 6 +++++ docs/testing.md | 51 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 docs/testing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 114ae77..c577c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes will be documented here. The project intends to follow seman ### Added +- Deterministic malformed-input corpus for generic bytes, JPEG, WebP, PNG, and shared TIFF corruption families. +- Cross-format invariants for deterministic inspection and cleaning, native-exception containment, fail-closed operations, input immutability, and cheap limit stress. +- Testing and fuzz-readiness guidance with future property and fuzz targets; random fuzzing remains outside normal CI. + - Bounded PNG chunk parsing with chunk-count, IEND, trailing-data, type, range, CRC-field, and compact CRC-32 validation. - PNG text, exact XMP `iTXt`, `eXIf`, ICC, timestamp, rendering/color, APNG, and unknown ancillary classification. - Shared TIFF/EXIF field decoding for exact bounded PNG `eXIf` data views. diff --git a/README.md b/README.md index 8677bd0..cede10e 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ GPS rational components remain exact numerator/denominator pairs; decimal coordi ## Security philosophy -Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, and malformed structures fail without unchecked access. PNG image data and compressed metadata are never inflated. Unknown JPEG APP segments, WebP chunks, and PNG ancillary chunks are preserved by default. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), and [cleaning policy](docs/cleaning-policy.md). +Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, and malformed structures fail without unchecked access. PNG image data and compressed metadata are never inflated. Unknown JPEG APP segments, WebP chunks, and PNG ancillary chunks are preserved by default. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), [testing model](docs/testing.md), and [cleaning policy](docs/cleaning-policy.md). ## Non-goals diff --git a/docs/architecture.md b/docs/architecture.md index 51c64b6..b4bfad2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,3 +31,16 @@ Traversal validates byte order, magic, complete IFD tables, field sizes, offset - `metadata-inspected`: reserved for future exhaustive metadata decoders. A report includes `metadataTruncated: true` when its entry budget is reached; a diagnostic is also emitted when the diagnostic budget permits. Verification fails closed rather than deriving absence from a truncated report. + +## Testing layers + +```text +bounded binary primitives + → format fixture tests + → deterministic malformed corpus + → cross-format invariants + → future property tests + → future fuzzing +``` + +Production parsers remain internal. Public operations are the preferred cross-format targets; the shared TIFF parser is directly callable only from test code. See [testing and fuzz readiness](testing.md). diff --git a/docs/security-model.md b/docs/security-model.md index 4847c26..d5d9e96 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -26,3 +26,9 @@ Malformed inner EXIF/TIFF or textual payloads do not block removal of their boun Verification reports only supported `present` or `absent` observations. Not-applicable format concepts produce no check. Truncated metadata reporting is recorded independently of diagnostic output, produces no checks, and fails verification. The library does not establish authenticity, provenance, absence of proprietary metadata, visible-person privacy, steganography safety, malware safety, or complete metadata absence. Core production code has zero runtime dependencies and no network, analytics, telemetry, filesystem, DOM, Node `Buffer`, or required platform-global behavior. + +## Malformed-input assurance + +Malformed input is part of the expected threat model. A small deterministic corpus covers representative generic, JPEG, WebP, PNG, and TIFF structural corruption families, including truncation, corrupt lengths and offsets, cycles, and configured work limits. Parser loops must advance or terminate, and unsafe outer container boundaries cause typed fail-closed cleaning and verification errors before output is produced. + +The corpus is regression coverage, not proof of parser correctness. No decompression is implemented, so decompression bombs are outside the current attack surface and `maxDecompressedBytes` remains reserved. Reproducible property testing and dedicated fuzzing are planned future layers; random fuzzing is not part of normal CI. See [testing and fuzz readiness](testing.md). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..e756fca --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,51 @@ +# Testing and Fuzz Readiness + +The test suite uses deterministic TypeScript fixture builders for JPEG markers, +WebP RIFF chunks, PNG chunks and CRCs, and TIFF IFD structures. Fixtures stay +small, readable, and cheap; binary files are used only when they would be +clearer than the builder expression. + +## Test layers + +```text +bounded binary primitives + → format fixtures and parser algorithms + → deterministic malformed corpus + → cross-format public API invariants + → future reproducible property tests + → future fuzz targets +``` + +The malformed corpus covers generic byte patterns plus representative JPEG, +WebP, PNG, and shared TIFF truncation, corrupt length, invalid offset, cycle, +and configured-limit families. Corpus assertions focus on stable contracts: +format/status, relevant diagnostic codes, deterministic results, caller-input +immutability, typed fail-closed cleaning and verification, and safe removal of +bounded malformed metadata. They intentionally avoid full-report snapshots and +timing thresholds. + +Security-limit tests use tiny inputs with small custom values for input, +segment, chunk, IFD entry/depth, metadata entry, string, and diagnostic limits. +`maxDecompressedBytes` remains unused because the library performs no +decompression. + +## Future property and fuzz targets + +Likely targets are: + +- `inspectMetadata(bytes)` through the public API; +- bounded JPEG, WebP, and PNG parser entry points in test/fuzz builds; +- the bounded TIFF parser as a test-only internal target; +- `cleanMetadata(bytes, policy)` through the public API. + +Strong future properties include containment of native bounds exceptions, +deterministic inspection and cleaning, re-inspectable clean output, Privacy +Clean idempotency, input immutability, removal-only output sizing, preservation +of unknown structures, and default ICC preservation. WebP is permitted to patch +the RIFF size and applicable VP8X metadata flags. + +No random fuzzing runs in normal CI, and no property/fuzz dependency is +currently installed. A future sprint can add reproducible seeded property tests +or dedicated fuzz harnesses if their coverage benefit justifies the development +dependency and CI cost. The deterministic corpus is regression coverage, not a +proof of parser correctness.