Skip to content

Commit 82204f7

Browse files
authored
Bound per-tile allocations in TIFF reader (#1215) (#1216)
`_check_dimensions` validates image dims but not tile dims. A TIFF claiming a 1x1 image with a 2^30 x 2^30 tile passes the guard and then asks the decompressor for terabytes. LZW allocates an `np.empty` of the claimed decompressed size, and the GPU path does `cupy.zeros(n_tiles * tile_bytes)`. Call `_check_dimensions` on the tile dims in `_read_tiles`, `_read_cog_http`, and `read_geotiff_gpu`. Reject zero tile dims before they reach the `math.ceil(width / tw)` division. Tests forge tile dims both at the `_read_tiles` level and the `open_geotiff` end-to-end level, and confirm real tile sizes (256, 512) still pass. Also records the geotiff audit in `.claude/sweep-security-state.json`.
1 parent 2ece86f commit 82204f7

File tree

4 files changed

+141
-0
lines changed

4 files changed

+141
-0
lines changed

.claude/sweep-security-state.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"inspections": {
3+
"reproject": {
4+
"last_inspected": "2026-04-17",
5+
"issue": null,
6+
"severity_max": "MEDIUM",
7+
"categories_found": [1, 3]
8+
},
9+
"geotiff": {
10+
"last_inspected": "2026-04-17",
11+
"issue": 1215,
12+
"severity_max": "HIGH",
13+
"categories_found": [1, 4]
14+
}
15+
}
16+
}

xrspatial/geotiff/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,7 +1127,13 @@ def read_geotiff_gpu(source: str, *,
11271127
width = ifd.width
11281128
height = ifd.height
11291129

1130+
if tw <= 0 or th <= 0:
1131+
raise ValueError(
1132+
f"Invalid tile dimensions: TileWidth={tw}, TileLength={th}")
1133+
11301134
_check_dimensions(width, height, samples, max_pixels)
1135+
# A single tile's decoded bytes must also fit under the pixel budget.
1136+
_check_dimensions(tw, th, samples, max_pixels)
11311137

11321138
finally:
11331139
src.close()

xrspatial/geotiff/_reader.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,14 @@ def _read_tiles(data: bytes, ifd: IFD, header: TIFFHeader,
475475
if offsets is None or byte_counts is None:
476476
raise ValueError("Missing tile offsets or byte counts")
477477

478+
if tw <= 0 or th <= 0:
479+
raise ValueError(
480+
f"Invalid tile dimensions: TileWidth={tw}, TileLength={th}")
481+
482+
# Reject crafted tile dims that would force huge per-tile allocations.
483+
# A single tile's decoded bytes must also fit under the pixel budget.
484+
_check_dimensions(tw, th, samples, max_pixels)
485+
478486
planar = ifd.planar_config
479487
tiles_across = math.ceil(width / tw)
480488
tiles_down = math.ceil(height / th)
@@ -645,10 +653,16 @@ def _read_cog_http(url: str, overview_level: int | None = None,
645653
offsets = ifd.tile_offsets
646654
byte_counts = ifd.tile_byte_counts
647655

656+
if tw <= 0 or th <= 0:
657+
raise ValueError(
658+
f"Invalid tile dimensions: TileWidth={tw}, TileLength={th}")
659+
648660
tiles_across = math.ceil(width / tw)
649661
tiles_down = math.ceil(height / th)
650662

651663
_check_dimensions(width, height, samples, max_pixels)
664+
# A single tile's decoded bytes must also fit under the pixel budget.
665+
_check_dimensions(tw, th, samples, max_pixels)
652666

653667
if samples > 1:
654668
result = np.empty((height, width, samples), dtype=dtype)

xrspatial/geotiff/tests/test_security.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,111 @@ def test_open_geotiff_max_pixels(self, tmp_path):
136136
open_geotiff(path, max_pixels=10)
137137

138138

139+
# ---------------------------------------------------------------------------
140+
# Cat 1c: Tile dimension guard (issue #1215)
141+
# ---------------------------------------------------------------------------
142+
143+
class TestTileDimensionGuard:
144+
"""Per-tile dims must also respect max_pixels, not just image dims.
145+
146+
A crafted TIFF can declare a tiny image while claiming a 2^30 x 2^30
147+
tile. Without this guard, _decode_strip_or_tile asks the decompressor
148+
for terabytes.
149+
"""
150+
151+
def test_read_tiles_rejects_huge_tile_dims(self):
152+
"""_read_tiles refuses to decode when tile dims would OOM."""
153+
data = make_minimal_tiff(8, 8, np.dtype('float32'),
154+
tiled=True, tile_size=4)
155+
header = parse_header(data)
156+
ifds = parse_all_ifds(data, header)
157+
ifd = ifds[0]
158+
159+
# Forge tile_width / tile_length to simulate an attacker-controlled
160+
# header. Image dims stay small so the image-level check passes.
161+
from xrspatial.geotiff._header import IFDEntry
162+
ifd.entries[322] = IFDEntry(tag=322, type_id=4, count=1,
163+
value=1_000_000)
164+
ifd.entries[323] = IFDEntry(tag=323, type_id=4, count=1,
165+
value=1_000_000)
166+
167+
dtype = tiff_dtype_to_numpy(ifd.bits_per_sample, ifd.sample_format)
168+
169+
with pytest.raises(ValueError, match="exceed the safety limit"):
170+
_read_tiles(data, ifd, header, dtype, max_pixels=1_000_000)
171+
172+
def test_read_tiles_rejects_zero_tile_dims(self):
173+
"""_read_tiles rejects tile dims of zero rather than dividing by 0."""
174+
data = make_minimal_tiff(8, 8, np.dtype('float32'),
175+
tiled=True, tile_size=4)
176+
header = parse_header(data)
177+
ifds = parse_all_ifds(data, header)
178+
ifd = ifds[0]
179+
180+
from xrspatial.geotiff._header import IFDEntry
181+
ifd.entries[322] = IFDEntry(tag=322, type_id=4, count=1, value=0)
182+
ifd.entries[323] = IFDEntry(tag=323, type_id=4, count=1, value=0)
183+
184+
dtype = tiff_dtype_to_numpy(ifd.bits_per_sample, ifd.sample_format)
185+
186+
with pytest.raises(ValueError, match="Invalid tile dimensions"):
187+
_read_tiles(data, ifd, header, dtype, max_pixels=1_000_000)
188+
189+
def test_normal_tile_dims_pass(self, tmp_path):
190+
"""Legitimate tile_size=4 on an 8x8 image still works."""
191+
expected = np.arange(64, dtype=np.float32).reshape(8, 8)
192+
data = make_minimal_tiff(8, 8, np.dtype('float32'),
193+
pixel_data=expected,
194+
tiled=True, tile_size=4)
195+
path = str(tmp_path / "tile_dims_1215.tif")
196+
with open(path, 'wb') as f:
197+
f.write(data)
198+
199+
# max_pixels=1000 is generous enough for a 4x4 tile (16 pixels)
200+
arr, _ = read_to_array(path, max_pixels=1000)
201+
np.testing.assert_array_equal(arr, expected)
202+
203+
def test_open_geotiff_forged_tile_dims(self, tmp_path):
204+
"""End-to-end: open_geotiff rejects a TIFF with forged tile dims.
205+
206+
Writes a real TIFF file with a small image but a huge TileWidth
207+
field, then checks that open_geotiff raises rather than OOMing.
208+
"""
209+
from xrspatial.geotiff import open_geotiff
210+
211+
# Build a tiny tiled TIFF, then patch the tile_width field in the
212+
# raw bytes. make_minimal_tiff stores tile_width as a SHORT at
213+
# tag 322, so we re-parse, find the entry, and overwrite the
214+
# inline value with a 32-bit LONG pointing at a huge number.
215+
base = make_minimal_tiff(8, 8, np.dtype('float32'),
216+
tiled=True, tile_size=4)
217+
path = str(tmp_path / "forged_tile_1215.tif")
218+
with open(path, 'wb') as f:
219+
f.write(base)
220+
221+
# Parse to locate the tile-width entry, then rewrite it in place.
222+
# The conftest TIFF uses little-endian SHORT for TileWidth (322).
223+
import struct
224+
header = parse_header(base)
225+
# IFD starts at offset 8, then 2-byte count, then 12-byte entries
226+
num_entries = struct.unpack_from('<H', base, 8)[0]
227+
patched = bytearray(base)
228+
for i in range(num_entries):
229+
eo = 10 + i * 12
230+
tag = struct.unpack_from('<H', patched, eo)[0]
231+
if tag == 322 or tag == 323:
232+
# Rewrite as LONG (type=4), count=1, value=1_000_000
233+
struct.pack_into('<HHII', patched, eo,
234+
tag, 4, 1, 1_000_000)
235+
236+
forged_path = str(tmp_path / "forged_1215_huge.tif")
237+
with open(forged_path, 'wb') as f:
238+
f.write(bytes(patched))
239+
240+
with pytest.raises(ValueError, match="exceed the safety limit"):
241+
open_geotiff(forged_path, max_pixels=1_000_000)
242+
243+
139244
# ---------------------------------------------------------------------------
140245
# Cat 1b: VRT allocation guard (issue #1195)
141246
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)