diff --git a/.claude/sweep-security-state.json b/.claude/sweep-security-state.json new file mode 100644 index 00000000..cdd13ee3 --- /dev/null +++ b/.claude/sweep-security-state.json @@ -0,0 +1,16 @@ +{ + "inspections": { + "reproject": { + "last_inspected": "2026-04-17", + "issue": null, + "severity_max": "MEDIUM", + "categories_found": [1, 3] + }, + "geotiff": { + "last_inspected": "2026-04-17", + "issue": 1215, + "severity_max": "HIGH", + "categories_found": [1, 4] + } + } +} diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index 8f4b6f64..9b344bb0 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -1106,7 +1106,13 @@ def read_geotiff_gpu(source: str, *, width = ifd.width height = ifd.height + if tw <= 0 or th <= 0: + raise ValueError( + f"Invalid tile dimensions: TileWidth={tw}, TileLength={th}") + _check_dimensions(width, height, samples, max_pixels) + # A single tile's decoded bytes must also fit under the pixel budget. + _check_dimensions(tw, th, samples, max_pixels) finally: src.close() diff --git a/xrspatial/geotiff/_reader.py b/xrspatial/geotiff/_reader.py index d991f123..669abf03 100644 --- a/xrspatial/geotiff/_reader.py +++ b/xrspatial/geotiff/_reader.py @@ -475,6 +475,14 @@ def _read_tiles(data: bytes, ifd: IFD, header: TIFFHeader, if offsets is None or byte_counts is None: raise ValueError("Missing tile offsets or byte counts") + if tw <= 0 or th <= 0: + raise ValueError( + f"Invalid tile dimensions: TileWidth={tw}, TileLength={th}") + + # Reject crafted tile dims that would force huge per-tile allocations. + # A single tile's decoded bytes must also fit under the pixel budget. + _check_dimensions(tw, th, samples, max_pixels) + planar = ifd.planar_config tiles_across = math.ceil(width / tw) tiles_down = math.ceil(height / th) @@ -645,10 +653,16 @@ def _read_cog_http(url: str, overview_level: int | None = None, offsets = ifd.tile_offsets byte_counts = ifd.tile_byte_counts + if tw <= 0 or th <= 0: + raise ValueError( + f"Invalid tile dimensions: TileWidth={tw}, TileLength={th}") + tiles_across = math.ceil(width / tw) tiles_down = math.ceil(height / th) _check_dimensions(width, height, samples, max_pixels) + # A single tile's decoded bytes must also fit under the pixel budget. + _check_dimensions(tw, th, samples, max_pixels) if samples > 1: result = np.empty((height, width, samples), dtype=dtype) diff --git a/xrspatial/geotiff/tests/test_security.py b/xrspatial/geotiff/tests/test_security.py index a230b28b..385714ea 100644 --- a/xrspatial/geotiff/tests/test_security.py +++ b/xrspatial/geotiff/tests/test_security.py @@ -136,6 +136,111 @@ def test_open_geotiff_max_pixels(self, tmp_path): open_geotiff(path, max_pixels=10) +# --------------------------------------------------------------------------- +# Cat 1c: Tile dimension guard (issue #1215) +# --------------------------------------------------------------------------- + +class TestTileDimensionGuard: + """Per-tile dims must also respect max_pixels, not just image dims. + + A crafted TIFF can declare a tiny image while claiming a 2^30 x 2^30 + tile. Without this guard, _decode_strip_or_tile asks the decompressor + for terabytes. + """ + + def test_read_tiles_rejects_huge_tile_dims(self): + """_read_tiles refuses to decode when tile dims would OOM.""" + data = make_minimal_tiff(8, 8, np.dtype('float32'), + tiled=True, tile_size=4) + header = parse_header(data) + ifds = parse_all_ifds(data, header) + ifd = ifds[0] + + # Forge tile_width / tile_length to simulate an attacker-controlled + # header. Image dims stay small so the image-level check passes. + from xrspatial.geotiff._header import IFDEntry + ifd.entries[322] = IFDEntry(tag=322, type_id=4, count=1, + value=1_000_000) + ifd.entries[323] = IFDEntry(tag=323, type_id=4, count=1, + value=1_000_000) + + dtype = tiff_dtype_to_numpy(ifd.bits_per_sample, ifd.sample_format) + + with pytest.raises(ValueError, match="exceed the safety limit"): + _read_tiles(data, ifd, header, dtype, max_pixels=1_000_000) + + def test_read_tiles_rejects_zero_tile_dims(self): + """_read_tiles rejects tile dims of zero rather than dividing by 0.""" + data = make_minimal_tiff(8, 8, np.dtype('float32'), + tiled=True, tile_size=4) + header = parse_header(data) + ifds = parse_all_ifds(data, header) + ifd = ifds[0] + + from xrspatial.geotiff._header import IFDEntry + ifd.entries[322] = IFDEntry(tag=322, type_id=4, count=1, value=0) + ifd.entries[323] = IFDEntry(tag=323, type_id=4, count=1, value=0) + + dtype = tiff_dtype_to_numpy(ifd.bits_per_sample, ifd.sample_format) + + with pytest.raises(ValueError, match="Invalid tile dimensions"): + _read_tiles(data, ifd, header, dtype, max_pixels=1_000_000) + + def test_normal_tile_dims_pass(self, tmp_path): + """Legitimate tile_size=4 on an 8x8 image still works.""" + expected = np.arange(64, dtype=np.float32).reshape(8, 8) + data = make_minimal_tiff(8, 8, np.dtype('float32'), + pixel_data=expected, + tiled=True, tile_size=4) + path = str(tmp_path / "tile_dims_1215.tif") + with open(path, 'wb') as f: + f.write(data) + + # max_pixels=1000 is generous enough for a 4x4 tile (16 pixels) + arr, _ = read_to_array(path, max_pixels=1000) + np.testing.assert_array_equal(arr, expected) + + def test_open_geotiff_forged_tile_dims(self, tmp_path): + """End-to-end: open_geotiff rejects a TIFF with forged tile dims. + + Writes a real TIFF file with a small image but a huge TileWidth + field, then checks that open_geotiff raises rather than OOMing. + """ + from xrspatial.geotiff import open_geotiff + + # Build a tiny tiled TIFF, then patch the tile_width field in the + # raw bytes. make_minimal_tiff stores tile_width as a SHORT at + # tag 322, so we re-parse, find the entry, and overwrite the + # inline value with a 32-bit LONG pointing at a huge number. + base = make_minimal_tiff(8, 8, np.dtype('float32'), + tiled=True, tile_size=4) + path = str(tmp_path / "forged_tile_1215.tif") + with open(path, 'wb') as f: + f.write(base) + + # Parse to locate the tile-width entry, then rewrite it in place. + # The conftest TIFF uses little-endian SHORT for TileWidth (322). + import struct + header = parse_header(base) + # IFD starts at offset 8, then 2-byte count, then 12-byte entries + num_entries = struct.unpack_from('