import { categoryForName, FindingCollector } from "./findings"; import { scanIptc } from "./iptc"; import type { PrivacyLimits } from "./model"; import { scanXmp } from "./xmp"; const TYPE_BYTES: Readonly> = Object.freeze({ 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8, 11: 4, 12: 8, 13: 4, }); const TAG_NAMES: Readonly> = Object.freeze({ 0x010e: "Image Description", 0x010f: "Camera Make", 0x0110: "Camera Model", 0x0112: "Orientation", 0x0131: "Software", 0x0132: "Date/Time", 0x013b: "Artist", 0x0201: "JPEG Thumbnail Offset", 0x0202: "JPEG Thumbnail Length", 0x02bc: "XMP", 0x8298: "Copyright", 0x83bb: "IPTC/NAA", 0x8769: "Exif IFD", 0x8825: "GPS IFD", 0x9003: "Date/Time Original", 0x9004: "Date/Time Digitized", 0x927c: "Maker Note", 0x9286: "User Comment", 0xa005: "Interoperability IFD", 0xa420: "Image Unique ID", 0xa430: "Camera Owner Name", 0xa431: "Camera Body Serial Number", 0xa432: "Lens Specification", 0xa433: "Lens Make", 0xa434: "Lens Model", 0xa435: "Lens Serial Number", 0x9c9b: "Windows Title", 0x9c9c: "Windows Comment", 0x9c9d: "Windows Author", 0x9c9e: "Windows Keywords", 0x9c9f: "Windows Subject", 0x8773: "ICC Profile", }); const GPS_NAMES: Readonly> = Object.freeze({ 0: "GPS Version", 1: "GPS Latitude Reference", 2: "GPS Latitude", 3: "GPS Longitude Reference", 4: "GPS Longitude", 5: "GPS Altitude Reference", 6: "GPS Altitude", 7: "GPS Time Stamp", 11: "GPS Dilution of Precision", 12: "GPS Speed Reference", 13: "GPS Speed", 16: "GPS Direction Reference", 17: "GPS Direction", 18: "GPS Map Datum", 27: "GPS Processing Method", 28: "GPS Area Information", 29: "GPS Date Stamp", 31: "GPS Horizontal Positioning Error", }); export interface TiffScan { orientation?: number; complete: boolean; } interface IfdTask { offset: number; role: "main" | "exif" | "gps" | "interop" | "thumbnail"; depth: number; } export function scanTiff( bytes: Uint8Array, source: string, baseOffset: number, collector: FindingCollector, limits: Readonly, ): TiffScan { if (bytes.byteLength < 8) { collector.warn(`${source} TIFF data is truncated.`); return { complete: false }; } const littleEndian = bytes[0] === 0x49 && bytes[1] === 0x49; const bigEndian = bytes[0] === 0x4d && bytes[1] === 0x4d; if (!littleEndian && !bigEndian) { collector.warn(`${source} TIFF byte order is invalid.`); return { complete: false }; } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const u16 = (offset: number) => view.getUint16(offset, littleEndian); const u32 = (offset: number) => view.getUint32(offset, littleEndian); if (u16(2) !== 42) { collector.warn(`${source} TIFF magic is unsupported.`); return { complete: false }; } const tasks: IfdTask[] = [{ offset: u32(4), role: "main", depth: 0 }]; const visited = new Set(); let totalEntries = 0; let orientation: number | undefined; let complete = true; while (tasks.length > 0) { const task = tasks.pop(); if (!task) break; if (task.depth > limits.maxTiffDepth) { collector.warn( `${source} TIFF IFD depth exceeded ${limits.maxTiffDepth}.`, ); complete = false; continue; } if (task.offset === 0) continue; if (visited.has(task.offset)) { collector.warn( `${source} TIFF IFD cycle was stopped at offset ${task.offset}.`, ); complete = false; continue; } visited.add(task.offset); if (task.offset < 8 || task.offset + 2 > bytes.byteLength) { collector.warn( `${source} TIFF IFD offset is outside the metadata block.`, ); complete = false; continue; } const count = u16(task.offset); totalEntries += count; if (totalEntries > limits.maxTiffEntries) { collector.warn( `${source} TIFF entry count exceeded ${limits.maxTiffEntries}.`, ); complete = false; break; } const tableEnd = task.offset + 2 + count * 12; if (tableEnd + 4 > bytes.byteLength) { collector.warn(`${source} TIFF IFD entry table is truncated.`); complete = false; continue; } for (let index = 0; index < count; index += 1) { const entryOffset = task.offset + 2 + index * 12; const tag = u16(entryOffset); const type = u16(entryOffset + 2); const itemCount = u32(entryOffset + 4); const unit = TYPE_BYTES[type]; if (!unit) continue; const byteLength = itemCount * unit; if ( !Number.isSafeInteger(byteLength) || byteLength > limits.maxMetadataBlockBytes ) { collector.warn( `${source} TIFF tag 0x${tag.toString(16)} is oversized.`, ); complete = false; continue; } const dataOffset = byteLength <= 4 ? entryOffset + 8 : u32(entryOffset + 8); if (dataOffset + byteLength > bytes.byteLength) { collector.warn( `${source} TIFF tag 0x${tag.toString(16)} points outside its block.`, ); complete = false; continue; } const data = bytes.subarray(dataOffset, dataOffset + byteLength); const values = readValues( view, dataOffset, itemCount, type, littleEndian, ); const first = values[0]; if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) { if (typeof first === "number") { tasks.push({ offset: first, role: tag === 0x8825 ? "gps" : tag === 0xa005 ? "interop" : "exif", depth: task.depth + 1, }); } continue; } if (tag === 0x014a) { for (const value of values) if (typeof value === "number") tasks.push({ offset: value, role: "main", depth: task.depth + 1 }); continue; } if (tag === 0x0112 && typeof first === "number") orientation = first; if (tag === 0x02bc) { scanXmp(data, `${source} XMP tag`, baseOffset + dataOffset, collector); continue; } if (tag === 0x83bb) { scanIptc( data, `${source} IPTC tag`, baseOffset + dataOffset, collector, ); continue; } const label = task.role === "gps" ? (GPS_NAMES[tag] ?? `GPS tag 0x${tag.toString(16)}`) : TAG_NAMES[tag]; if (!label) continue; const classification = task.role === "gps" ? ({ category: "location", risk: "sensitive" } as const) : categoryForName(label); collector.add({ ...classification, source, label, value: tag === 0x0112 && typeof first === "number" ? orientationName(first) : displayTiffValue(data, values, type, tag), offset: baseOffset + dataOffset, length: byteLength, }); } const next = u32(tableEnd); if (next !== 0) tasks.push({ offset: next, role: task.role === "main" ? "thumbnail" : task.role, depth: task.depth + 1, }); } return { orientation, complete }; } function readValues( view: DataView, offset: number, count: number, type: number, littleEndian: boolean, ): Array { if (type === 2) { const bytes = new Uint8Array(view.buffer, view.byteOffset + offset, count); return [ stripTerminalNulls( new TextDecoder("utf-8", { fatal: false }).decode(bytes), ), ]; } const result: Array = []; const maximum = Math.min(count, 128); for (let index = 0; index < maximum; index += 1) { const itemOffset = offset + index * (type === 3 ? 2 : type === 4 || type === 9 || type === 11 ? 4 : type === 5 || type === 10 || type === 12 ? 8 : 1); if (type === 1 || type === 7) result.push(view.getUint8(itemOffset)); else if (type === 3) result.push(view.getUint16(itemOffset, littleEndian)); else if (type === 4 || type === 13) result.push(view.getUint32(itemOffset, littleEndian)); else if (type === 9) result.push(view.getInt32(itemOffset, littleEndian)); else if (type === 11) result.push(view.getFloat32(itemOffset, littleEndian)); else if (type === 12) result.push(view.getFloat64(itemOffset, littleEndian)); else if (type === 5 || type === 10) { const numerator = type === 5 ? view.getUint32(itemOffset, littleEndian) : view.getInt32(itemOffset, littleEndian); const denominator = type === 5 ? view.getUint32(itemOffset + 4, littleEndian) : view.getInt32(itemOffset + 4, littleEndian); result.push( denominator === 0 ? `${numerator}/0` : numerator / denominator, ); } } if (count > maximum) result.push(`… ${count - maximum} more values`); return result; } function displayTiffValue( data: Uint8Array, values: Array, type: number, tag: number, ): string { if (type === 2) return String(values[0] ?? ""); if (tag >= 0x9c9b && tag <= 0x9c9f && data.byteLength % 2 === 0) return stripTerminalNulls( new TextDecoder("utf-16le", { fatal: false }).decode(data), ); if (tag === 0x927c || tag === 0x8773) return `${data.byteLength} bytes`; return values.join(", "); } function stripTerminalNulls(value: string): string { let end = value.length; while (end > 0 && value.charCodeAt(end - 1) === 0) end -= 1; return value.slice(0, end); } function orientationName(value: number): string { return ( [ "Unknown", "1 — normal", "2 — mirrored horizontally", "3 — rotated 180°", "4 — mirrored vertically", "5 — mirrored then rotated 90° clockwise", "6 — rotated 90° clockwise", "7 — mirrored then rotated 90° counter-clockwise", "8 — rotated 90° counter-clockwise", ][value] ?? `${value} — invalid orientation` ); }