Files
privacy-tools/src/privacy/tiff.ts
T
2026-09-01 02:39:44 +02:00

347 lines
10 KiB
TypeScript

import { categoryForName, FindingCollector } from "./findings";
import { scanIptc } from "./iptc";
import type { PrivacyLimits } from "./model";
import { scanXmp } from "./xmp";
const TYPE_BYTES: Readonly<Record<number, number>> = 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<Record<number, string>> = 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<Record<number, string>> = 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<PrivacyLimits>,
): 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<number>();
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<number | string> {
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<number | string> = [];
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<number | string>,
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`
);
}