347 lines
10 KiB
TypeScript
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`
|
|
);
|
|
}
|