Skip to content

Commit 9ad453d

Browse files
guiguili520claudeFei-Away
authored
fix(macos): reject oversized images before sips rasterizes them (#66) (#68)
* fix(macos): reject oversized images before sips rasterizes them load-image-theme capped only source bytes (50 MB) before running `sips -Z`, which must fully decode the source first — a near-flat 30000x30000 PNG under the byte cap balloons to gigabytes of pixels before the 16384px / 50MP limits (enforced only at inject time) apply. Add a header-only dimension preflight (check-image-dimensions.mjs): a new readRawDimensions() export parses raw PNG/JPEG/WebP header dimensions, with a `sips -g` metadata fallback for HEIC/TIFF, and rejects anything over the existing 16384px-per-side / 50MP caps before any decode happens. Closes #66 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(macos): fail closed when image dimensions are unknown --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Fei-Away <81107144@yonghui.cn>
1 parent f85db23 commit 9ad453d

5 files changed

Lines changed: 138 additions & 7 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Reject oversized images BEFORE anything rasterizes them.
2+
//
3+
// `load-image-theme` converts non-JPEG sources with `sips -Z`, which must
4+
// fully decode the source first — a near-flat 30000×30000 PNG under the 50 MB
5+
// byte cap would still balloon to gigabytes of pixels. This preflight reads the
6+
// container header only (PNG/JPEG/WebP) and falls back to `sips -g` metadata for
7+
// formats the header parser does not recognize (HEIC/TIFF); it never decodes.
8+
//
9+
// Exit 0 = dimensions are known and within caps,
10+
// 1 = over caps, 2 = usage / unreadable or undetermined dimensions.
11+
12+
import fs from "node:fs/promises";
13+
import path from "node:path";
14+
import { execFileSync } from "node:child_process";
15+
import {
16+
MAX_IMAGE_DIMENSION,
17+
MAX_IMAGE_PIXELS,
18+
readRawDimensions,
19+
} from "./image-metadata.mjs";
20+
21+
const file = process.argv[2];
22+
if (!file) {
23+
console.error("usage: check-image-dimensions.mjs <image>");
24+
process.exit(2);
25+
}
26+
27+
function overCaps(width, height) {
28+
return !Number.isSafeInteger(width) || !Number.isSafeInteger(height)
29+
|| width < 1 || height < 1
30+
|| width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION
31+
|| width * height > MAX_IMAGE_PIXELS;
32+
}
33+
34+
let dimensions = null;
35+
try {
36+
const bytes = new Uint8Array(await fs.readFile(file));
37+
dimensions = readRawDimensions(bytes, path.extname(file));
38+
} catch (error) {
39+
console.error(`Could not read image: ${error.message}`);
40+
process.exit(2);
41+
}
42+
43+
// HEIC/TIFF and anything the header parser does not recognize: ask sips for
44+
// image properties only. Reading properties does not rasterize the file.
45+
if (!dimensions) {
46+
try {
47+
const out = execFileSync(
48+
"/usr/bin/sips",
49+
["-g", "pixelWidth", "-g", "pixelHeight", file],
50+
{ encoding: "utf8", timeout: 10000 },
51+
);
52+
const width = Number(/pixelWidth:\s*(\d+)/.exec(out)?.[1]);
53+
const height = Number(/pixelHeight:\s*(\d+)/.exec(out)?.[1]);
54+
if (Number.isFinite(width) && Number.isFinite(height)) {
55+
dimensions = { width, height };
56+
}
57+
} catch {
58+
// sips unavailable or refused the file: fall through. The 50 MB byte cap and
59+
// the inject-time dimension check remain as backstops.
60+
}
61+
}
62+
63+
if (!dimensions) {
64+
console.error("Could not determine image dimensions without rasterizing the source.");
65+
process.exit(2);
66+
}
67+
68+
if (overCaps(dimensions.width, dimensions.height)) {
69+
console.error(
70+
`Image is ${dimensions.width}×${dimensions.height}px, over the `
71+
+ `${MAX_IMAGE_DIMENSION}px-per-side / ${MAX_IMAGE_PIXELS / 1_000_000}-megapixel safety limit.`,
72+
);
73+
process.exit(1);
74+
}
75+
process.exit(0);

macos/scripts/image-metadata.mjs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,21 @@ export function classifyImageDimensions({ width, height }) {
119119
};
120120
}
121121

122-
export function readImageMetadata(value, extension = "") {
122+
// Raw pixel dimensions straight from the container header — no decode, and no
123+
// safety-cap classification, so callers can reject oversized images *before*
124+
// anything rasterizes them. Returns null for formats this header parser does
125+
// not recognize (e.g. HEIC/TIFF).
126+
export function readRawDimensions(value, extension = "") {
123127
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
124128
const normalized = extension.toLowerCase();
125-
let dimensions = null;
126-
if (normalized === ".png" || bytes[0] === 0x89) dimensions = pngDimensions(bytes);
127-
else if (normalized === ".jpg" || normalized === ".jpeg" ||
128-
(bytes[0] === 0xff && bytes[1] === 0xd8)) dimensions = jpegDimensions(bytes);
129-
else if (normalized === ".webp" || ascii(bytes, 8, 4) === "WEBP") dimensions = webpDimensions(bytes);
129+
if (normalized === ".png" || bytes[0] === 0x89) return pngDimensions(bytes);
130+
if (normalized === ".jpg" || normalized === ".jpeg" ||
131+
(bytes[0] === 0xff && bytes[1] === 0xd8)) return jpegDimensions(bytes);
132+
if (normalized === ".webp" || ascii(bytes, 8, 4) === "WEBP") return webpDimensions(bytes);
133+
return null;
134+
}
135+
136+
export function readImageMetadata(value, extension = "") {
137+
const dimensions = readRawDimensions(value, extension);
130138
return dimensions ? classifyImageDimensions(dimensions) : null;
131139
}

macos/scripts/load-image-theme-macos.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ progress "Loading image..."
7878
# Fast Node for write-theme (avoid full codesign when possible)
7979
ensure_node_runtime
8080

81+
# Reject decompression bombs before `sips -Z` rasterizes the full source image.
82+
"$NODE" "$SCRIPT_DIR/check-image-dimensions.mjs" "$IMAGE" \
83+
|| fail "Image dimensions are invalid or exceed the safe pixel budget (max 16384 px per side / 50 megapixels)."
84+
8185
image_name="background.jpg"
8286
temporary="$THEME_DIR/.background.$$.tmp.jpg"
8387
prepared="$THEME_DIR/$image_name"

macos/tests/image-metadata.test.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
MAX_IMAGE_PIXELS,
88
classifyImageDimensions,
99
readImageMetadata,
10+
readRawDimensions,
1011
} from "../scripts/image-metadata.mjs";
1112

1213
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -104,4 +105,16 @@ assert.deepEqual(readImageMetadata(vp8x, ".webp"), {
104105

105106
assert.equal(readImageMetadata(new Uint8Array([0, 1, 2, 3]), ".png"), null);
106107

107-
console.log("PASS: image dimensions strictly classify PNG, JPEG, VP8L, and VP8X profiles.");
108+
// readRawDimensions returns real pixel dimensions even beyond the safety caps,
109+
// so a preflight can reject decompression bombs before anything decodes them.
110+
assert.deepEqual(readRawDimensions(portal, ".png"), { width: 2168, height: 725 });
111+
const oversized = new Uint8Array(24);
112+
oversized.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
113+
oversized.set([0x00, 0x00, 0x00, 0x0d], 8); // IHDR chunk length
114+
writeAscii(oversized, 12, "IHDR");
115+
oversized.set([0x00, 0x00, 0x4e, 0x20], 16); // width 20000
116+
oversized.set([0x00, 0x00, 0x4e, 0x20], 20); // height 20000
117+
assert.deepEqual(readRawDimensions(oversized, ".png"), { width: 20000, height: 20000 });
118+
assert.equal(readImageMetadata(oversized, ".png"), null); // 400 MP exceeds the cap
119+
120+
console.log("PASS: image dimensions strictly classify PNG, JPEG, VP8L, and VP8X profiles, and readRawDimensions bypasses caps.");

macos/tests/run-tests.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,37 @@ fi
191191
"$ROOT/tests/installer-preflight.test.sh"
192192
"$NODE" "$ROOT/tests/theme-config.test.mjs"
193193

194+
# check-image-dimensions rejects decompression bombs before sips can rasterize them.
195+
write_png_header() { # <path> <width> <height>
196+
"$NODE" -e '
197+
const fs = require("node:fs");
198+
const buffer = Buffer.alloc(24);
199+
Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]).copy(buffer, 0);
200+
buffer.writeUInt32BE(13, 8);
201+
buffer.write("IHDR", 12, "ascii");
202+
buffer.writeUInt32BE(Number(process.argv[2]), 16);
203+
buffer.writeUInt32BE(Number(process.argv[3]), 20);
204+
fs.writeFileSync(process.argv[1], buffer);
205+
' "$1" "$2" "$3"
206+
}
207+
CID_TMP="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/codex-dream-skin-cid.XXXXXX")"
208+
write_png_header "$CID_TMP/huge.png" 20000 20000
209+
if "$NODE" "$ROOT/scripts/check-image-dimensions.mjs" "$CID_TMP/huge.png" >/dev/null 2>&1; then
210+
printf 'check-image-dimensions accepted a 20000x20000 (400 MP) image.\n' >&2
211+
/bin/rm -rf "$CID_TMP"; exit 1
212+
fi
213+
write_png_header "$CID_TMP/ok.png" 1600 900
214+
if ! "$NODE" "$ROOT/scripts/check-image-dimensions.mjs" "$CID_TMP/ok.png" >/dev/null 2>&1; then
215+
printf 'check-image-dimensions rejected a valid 1600x900 image.\n' >&2
216+
/bin/rm -rf "$CID_TMP"; exit 1
217+
fi
218+
/usr/bin/printf 'not-an-image' > "$CID_TMP/invalid.png"
219+
if "$NODE" "$ROOT/scripts/check-image-dimensions.mjs" "$CID_TMP/invalid.png" >/dev/null 2>&1; then
220+
printf 'check-image-dimensions accepted an image whose dimensions could not be determined safely.\n' >&2
221+
/bin/rm -rf "$CID_TMP"; exit 1
222+
fi
223+
/bin/rm -rf "$CID_TMP"
224+
194225
# Every bundled preset must be a valid, injectable theme pack with a preset-* id.
195226
for preset in "$ROOT"/presets/preset-*/; do
196227
[ -d "$preset" ] || continue

0 commit comments

Comments
 (0)