@@ -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