Skip to content

Commit 3488ee0

Browse files
committed
Add decompression-bomb cap to LERC and JPEG 2000 codecs (#1625)
The deflate, zstd, lz4, and packbits wrappers all received a pre-decode output-size cap in #1533 so a crafted TIFF tile cannot expand to many GB before the post-decode size check fires. LERC and JPEG 2000 were missed: lerc_decompress_with_mask and jpeg2000_decompress called the underlying lerc.decode / glymur.Jp2k[:] with no bound, so the existing size check in _decode_strip_or_tile ran only after the full buffer had already been materialised by the external library. LERC compresses constant-value blocks at >700,000:1, so a 94-byte blob can request 64 MiB of host memory. A 1 KB on-disk LERC tile can ask for several GB before the reader rejects it. JPEG 2000 has similar amplification potential through codestream-declared dimensions. Fix: query each codestream's declared dimensions before decoding. - LERC: lerc.getLercBlobInfo(blob) returns nCols, nRows, nBands, and dataType from the header without decoding. Compute the projected output bytes and raise ValueError when it exceeds the same expected_size * 1.05 + 1 cap used by every other codec. - JPEG 2000: glymur.Jp2k(file).shape parses only the SIZ marker, so inspecting it before [:] is cheap. Pillow's JPEG decoder already has Image.MAX_IMAGE_PIXELS and raises DecompressionBombError, so no wrapper-level cap is needed there. Plumb expected_size through the decompress() dispatcher, _decode_strip_or_tile's LERC branch, and the _gpu_decode CPU fallbacks for both codecs. Tests cover bomb-rejection and legitimate round-trips at the codec level for both LERC and JPEG 2000, plus the existing zero-expected-size backward-compat path. Found by /sweep-security on the geotiff module.
1 parent 9a5f55e commit 3488ee0

5 files changed

Lines changed: 222 additions & 11 deletions

File tree

.claude/sweep-security-state.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ fire,2026-04-25,,,,,"Clean. Despite the module's size hint, fire.py is purely pe
1818
flood,2026-05-03,1437,MEDIUM,3,,Re-audit 2026-05-03. MEDIUM Cat 3 fixed in PR #1438 (travel_time and flood_depth_vegetation now validate mannings_n DataArray values are finite and strictly positive via _validate_mannings_n_dataarray helper). No remaining unfixed findings. Other categories clean: every allocation is same-shape as input; no flat index math; NaN propagation explicit in every backend; tan_slope clamped by _TAN_MIN; no CUDA kernels; no file I/O; every public API calls _validate_raster on DataArray inputs.
1919
focal,2026-04-27,1284,HIGH,1,,"HIGH (fixed PR #1286): apply(), focal_stats(), and hotspots() accepted unbounded user-supplied kernels via custom_kernel(), which only checks shape parity. The kernel-size guard from #1241 (_check_kernel_memory) only ran inside circle_kernel/annulus_kernel, so a (50001, 50001) custom kernel on a 10x10 raster allocated ~10 GB on the kernel itself plus a much larger padded raster before any work -- same shape as the bilateral DoS in #1236. Fixed by adding _check_kernel_vs_raster_memory in focal.py and wiring it into apply(), focal_stats(), and hotspots() after custom_kernel() validation. All 134 focal tests + 19 bilateral tests pass. No other findings: 10 CUDA kernels all have proper bounds + stencil guards; _validate_raster called on every public entry point; hotspots already raises ZeroDivisionError on constant-value rasters; _focal_variety_cuda uses a fixed-size local buffer (silent truncation but bounded); _focal_std_cuda/_focal_var_cuda clamp the catastrophic-cancellation case via if var < 0.0: var = 0.0; no file I/O."
2020
geodesic,2026-04-27,1283,HIGH,1,,"HIGH (fixed PR #1285): slope(method='geodesic') and aspect(method='geodesic') stack a (3, H, W) float64 array (data, lat, lon) before dispatch with no memory check. A large lat/lon-tagged raster passed to either function would OOM. Fixed by adding _check_geodesic_memory(rows, cols) in xrspatial/geodesic.py (mirrors morphology._check_kernel_memory): budgets 56 bytes/cell (24 stacked float64 + 4 float32 output + 24 padded copy + slack) and raises MemoryError when > 50% of available RAM; called from slope.py and aspect.py inside the geodesic branch before dispatch. No other findings: 6 CUDA kernels all have bounds guards (e.g. _run_gpu_geodesic_aspect at geodesic.py:395), custom 16x16 thread blocks avoid register spill, no shared memory, _validate_raster runs upstream in slope/aspect, all backends cast to float32, slope_mag < 1e-7 flat threshold prevents arctan2 NaN propagation, curvature correction uses hardcoded WGS84 R."
21-
geotiff,2026-05-11,1614,MEDIUM,5,,"MEDIUM (Cat 5 XML injection, filed #1614): _build_gdal_metadata_xml in _geotags.py used plain f-strings to embed caller-supplied keys and values into the GDALMetadata XML payload (tag 42112), so a key or value carrying XML special chars (< > & "" ') silently produced malformed XML (ParseError on read -> attrs round-tripped as {}) or let a crafted key inject attributes into <Item> (e.g. name='foo"" malicious=""bar'). Fix mirrors #1607: route every text slot through xml.sax.saxutils.escape and every attribute slot through quoteattr; sample indices are emitted from int() casts. Same bug class as #1607 but on a separate code path the earlier sweep did not cover. Other categories clean (rest of geotiff already hardened by #1607/#1579/#1584/#1219/#1196/#1189). Reachable from to_geotiff, _write_vrt_tiled, and write_geotiff_gpu whenever a caller passes attrs['gdal_metadata'] as a dict."
21+
geotiff,2026-05-11,1625,MEDIUM,1,,"Re-audit pass 14 2026-05-11: MEDIUM Cat 1 (decompression bomb, filed #1625): lerc_decompress_with_mask and jpeg2000_decompress called lerc.decode / glymur.Jp2k[:] with no pre-decode output-size bound. The post-decode size check in _decode_strip_or_tile fired only after the external library had already materialised the full buffer. A 94-byte LERC blob can declare a 64 MiB output; a kilobyte-sized blob can request multiple GB. Fix: added _check_lerc_bomb helper (queries lerc.getLercBlobInfo for declared nCols/nRows/nBands*dtype_bytes) and Jp2k.shape check in jpeg2000_decompress; both raise ValueError when declared output exceeds expected_size*1.05+1 cap, matching the deflate/zstd/lz4/packbits pattern from #1533. Wired expected_size through decompress() and _decode_strip_or_tile and _gpu_decode CPU fallback. JPEG codec is protected at the library level via Image.MAX_IMAGE_PIXELS so no wrapper-level cap is needed. Other categories remain clean (see prior pass notes)."
2222
glcm,2026-04-24,1257,HIGH,1,,"HIGH (fixed #1257): glcm_texture() validated window_size only as >= 3 and distance only as >= 1, with no upper bound on either. _glcm_numba_kernel iterates range(r-half, r+half+1) for every pixel, so window_size=1_000_001 on a 10x10 raster ran ~10^14 loop iterations with all neighbors failing the interior bounds check (CPU DoS). On the dask backends depth = window_size // 2 + distance drove map_overlap padding, so a huge window also caused oversize per-chunk allocations (memory DoS). Fixed by adding max_val caps in the public entrypoint: window_size <= max(3, min(rows, cols)) and distance <= max(1, window_size // 2). One cap covers every backend because cupy and dask+cupy call through to the CPU kernel after cupy.asnumpy. No other HIGH findings: levels is already capped at 256 so the per-pixel np.zeros((levels, levels)) matrix in the kernel is bounded to 512 KB. No CUDA kernels. No file I/O. Quantization clips to [0, levels-1] before the kernel and NaN maps to -1 which the kernel filters with i_val >= 0. Entropy log(p) and correlation p / (std_i * std_j) are both guarded. All four backends use _validate_raster and cast to float64 before quantizing. MEDIUM (unfixed, Cat 1): the per-pixel np.zeros((levels, levels)) allocation inside the hot loop is a perf issue (levels=256 -> 512 KB alloc+free per pixel) but not a security issue because levels is bounded. Could be hoisted out of the loop or replaced with an in-place clear, but that is an efficiency concern, not security."
2323
gpu_rtx,2026-04-29,1308,HIGH,1,,"HIGH (fixed #1308 / PR #1310): hillshade_rtx (gpu_rtx/hillshade.py:184) and viewshed_gpu (gpu_rtx/viewshed.py:269) allocated cupy device buffers sized by raster shape with no memory check. create_triangulation (mesh_utils.py:23-24) adds verts (12 B/px) + triangles (24 B/px) = 36 B/px; hillshade_rtx adds d_rays(32) + d_hits(16) + d_aux(12) + d_output(4) = 64 B/px (100 B/px total); viewshed_gpu adds d_rays(32) + d_hits(16) + d_visgrid(4) + d_vsrays(32) = 84 B/px (120 B/px total). A 30000x30000 raster asked for 90-108 GB of VRAM before cupy surfaced an opaque allocator error. Fixed by adding gpu_rtx/_memory.py with _available_gpu_memory_bytes() and _check_gpu_memory(func_name, h, w) helpers (cost_distance #1262 / sky_view_factor #1299 pattern, 120 B/px budget covers worst case, raises MemoryError when required > 50% of free VRAM, skips silently when memGetInfo() unavailable). Wired into both entry points after the cupy.ndarray type check and before create_triangulation. 9 new tests in test_gpu_rtx_memory.py (5 helper-unit + 4 end-to-end gated on has_rtx). All 81 existing hillshade/viewshed tests still pass. Cat 4 clean: all CUDA kernels (hillshade.py:25/62/106, viewshed.py:32/74/116, mesh_utils.py:50) have bounds guards; no shared memory, no syncthreads needed. MEDIUM not fixed (Cat 6): hillshade_rtx and viewshed_gpu do not call _validate_raster directly but parent hillshade() (hillshade.py:252) and viewshed() (viewshed.py:1707) already validate, so input validation runs before the gpu_rtx entry point - defense-in-depth, not exploitable. MEDIUM not fixed (Cat 2): mesh_utils.py:64-68 cast mesh_map_index to int32 in the triangle index buffer; overflows at H*W > 2.1B vertices (~46341x46341+) but the new memory guard rejects rasters that large first - documentation/clarity item rather than exploitable. MEDIUM not fixed (Cat 3): mesh_utils.py:19 scale = maxDim / maxH divides by zero on an all-zero raster, propagating inf/NaN into mesh vertex z-coords; separate follow-up. LOW not fixed (Cat 5): mesh_utils.write() opens user-supplied path without canonicalization but its only call site (mesh_utils.py:38-39) sits behind if False: in create_triangulation, not reachable in production."
2424
hillshade,2026-04-27,,,,,"Clean. Cat 1: only allocation is the output np.empty(data.shape) at line 32 (cupy at line 165) and a _pad_array with hardcoded depth=1 (line 62) -- bounded by caller, no user-controlled amplifier. Azimuth/altitude are scalars and don't drive size. Cat 2: numba kernel uses range(1, rows-1) with simple (y, x) indexing; numba range loops promote to int64. Cat 3: math.sqrt(1.0 + xx_plus_yy) is always >= 1.0 (no neg sqrt, no div-by-zero); NaN elevation propagates correctly through dz_dx/dz_dy -> shaded -> output (the shaded < 0.0 / shaded > 1.0 clamps don't fire on NaN). Azimuth validated to [0, 360], altitude to [0, 90]. Cat 4: _gpu_calc_numba (line 107) guards both grid bounds and 3x3 stencil reads via i > 0 and i < shape[0]-1 and j > 0 and j < shape[1]-1; no shared memory. Cat 5: no file I/O. Cat 6: hillshade() calls _validate_raster (line 252) and _validate_scalar for both azimuth (253) and angle_altitude (254); all four backend paths cast to float32; tests parametrize int32/int64/float32/float64."

xrspatial/geotiff/_compression.py

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,8 +1112,17 @@ def zstd_compress(data: bytes, level: int = 3) -> bytes:
11121112

11131113

11141114
def jpeg2000_decompress(data: bytes, width: int = 0, height: int = 0,
1115-
samples: int = 1) -> bytes:
1116-
"""Decompress a JPEG 2000 codestream. Requires ``glymur``."""
1115+
samples: int = 1, expected_size: int = 0) -> bytes:
1116+
"""Decompress a JPEG 2000 codestream. Requires ``glymur``.
1117+
1118+
When ``expected_size`` > 0 the wrapper inspects the codestream's
1119+
declared ``shape`` and ``dtype`` via :class:`glymur.Jp2k` (which
1120+
parses only the SIZ marker and does not trigger pixel decoding)
1121+
and raises ``ValueError`` when ``np.prod(shape) * dtype_bytes``
1122+
exceeds ``expected_size * 1.05 + 1`` bytes. This blocks
1123+
decompression-bomb attacks where a tiny on-disk JPEG 2000 tile
1124+
declares multi-gigabyte output dimensions.
1125+
"""
11171126
if not JPEG2000_AVAILABLE:
11181127
raise ImportError(
11191128
"glymur is required to read JPEG 2000-compressed TIFFs. "
@@ -1126,6 +1135,24 @@ def jpeg2000_decompress(data: bytes, width: int = 0, height: int = 0,
11261135
os.write(fd, data)
11271136
os.close(fd)
11281137
jp2 = _glymur.Jp2k(tmp)
1138+
if expected_size > 0:
1139+
try:
1140+
shape = jp2.shape
1141+
dtype = np.dtype(getattr(jp2, 'dtype', np.uint8))
1142+
except Exception:
1143+
shape = None
1144+
dtype = None
1145+
if shape is not None and dtype is not None:
1146+
declared = int(np.prod(shape)) * dtype.itemsize
1147+
cap = _max_output_with_margin(expected_size)
1148+
if declared > cap:
1149+
raise ValueError(
1150+
f"jpeg2000 decode would exceed expected size: "
1151+
f"declared output is {declared} bytes (shape "
1152+
f"{shape}, {dtype.itemsize} B/sample), cap is "
1153+
f"{cap} (expected {expected_size}). Likely a "
1154+
f"decompression bomb."
1155+
)
11291156
arr = jp2[:]
11301157
return arr.tobytes()
11311158
finally:
@@ -1174,21 +1201,75 @@ def jpeg2000_compress(data: bytes, width: int, height: int,
11741201
_lerc = None
11751202

11761203

1204+
# LERC dataType code -> bytes/sample. Mirrors the enum in the LERC C++
1205+
# header: 0 int8, 1 uint8, 2 int16, 3 uint16, 4 int32, 5 uint32,
1206+
# 6 float32, 7 float64. Used by the decompression-bomb pre-check on
1207+
# ``lerc.getLercBlobInfo`` so the blob's declared decoded byte count can
1208+
# be validated before ``lerc.decode`` allocates the full buffer.
1209+
_LERC_DTYPE_BYTES = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 8}
1210+
1211+
1212+
def _check_lerc_bomb(data: bytes, expected_size: int) -> None:
1213+
"""Reject LERC blobs whose declared output exceeds the bomb cap.
1214+
1215+
``lerc.getLercBlobInfo`` parses the blob header without decoding,
1216+
returning ``(errCode, version, dataType, nDim, nCols, nRows,
1217+
nBands, ...)``. We compute ``nCols * nRows * nBands * dtype_bytes``
1218+
and raise ``ValueError`` when the projected output exceeds the
1219+
same margin cap (``expected_size * 1.05 + 1``) used by every other
1220+
codec wrapper. Skipping when ``expected_size <= 0`` matches the
1221+
existing convention: a zero (or unset) expected size disables the
1222+
cap so direct callers and round-trip tests still work.
1223+
"""
1224+
if expected_size <= 0:
1225+
return
1226+
try:
1227+
info = _lerc.getLercBlobInfo(data)
1228+
except Exception:
1229+
# If the header itself is malformed, hand the blob to lerc.decode
1230+
# so it produces the canonical error rather than masking it here.
1231+
return
1232+
if len(info) < 7:
1233+
return
1234+
data_type = int(info[2])
1235+
n_cols = int(info[4])
1236+
n_rows = int(info[5])
1237+
n_bands = int(info[6])
1238+
bytes_per_sample = _LERC_DTYPE_BYTES.get(data_type)
1239+
if bytes_per_sample is None:
1240+
return
1241+
declared = n_cols * n_rows * n_bands * bytes_per_sample
1242+
cap = _max_output_with_margin(expected_size)
1243+
if declared > cap:
1244+
raise ValueError(
1245+
f"lerc decode would exceed expected size: declared output is "
1246+
f"{declared} bytes ({n_cols}x{n_rows}x{n_bands}, "
1247+
f"{bytes_per_sample} B/sample), cap is {cap} "
1248+
f"(expected {expected_size}). Likely a decompression bomb."
1249+
)
1250+
1251+
11771252
def lerc_decompress(data: bytes, width: int = 0, height: int = 0,
1178-
samples: int = 1) -> bytes:
1253+
samples: int = 1, expected_size: int = 0) -> bytes:
11791254
"""Decompress LERC data. Requires the ``lerc`` package.
11801255
11811256
Returns the raw decoded pixel bytes. Any LERC valid-mask is dropped
11821257
here; masked pixels are returned as LERC's zero fill (the wire
11831258
format's default). Callers that need to honour the file's nodata
11841259
value should use :func:`lerc_decompress_with_mask` instead and apply
11851260
nodata at the array level once dtype is known.
1261+
1262+
When ``expected_size`` > 0 the wrapper queries the blob's declared
1263+
output size via :func:`lerc.getLercBlobInfo` and raises
1264+
``ValueError`` when it exceeds ``expected_size * 1.05 + 1`` bytes,
1265+
matching the bomb cap applied by every other codec wrapper.
11861266
"""
1187-
decoded_bytes, _mask = lerc_decompress_with_mask(data)
1267+
decoded_bytes, _mask = lerc_decompress_with_mask(
1268+
data, expected_size=expected_size)
11881269
return decoded_bytes
11891270

11901271

1191-
def lerc_decompress_with_mask(data: bytes):
1272+
def lerc_decompress_with_mask(data: bytes, expected_size: int = 0):
11921273
"""Decompress LERC data and return ``(bytes, valid_mask_or_None)``.
11931274
11941275
``valid_mask`` is ``None`` when LERC reports the block is fully
@@ -1198,11 +1279,21 @@ def lerc_decompress_with_mask(data: bytes):
11981279
pixels the encoder flagged as invalid. LERC zero fills masked
11991280
positions in the data array, so the returned mask is the only
12001281
signal that lets a reader restore the file's nodata sentinel.
1282+
1283+
When ``expected_size`` > 0 the wrapper queries the blob's declared
1284+
output size via :func:`lerc.getLercBlobInfo` and raises
1285+
``ValueError`` when it exceeds ``expected_size * 1.05 + 1`` bytes
1286+
(decompression-bomb guard). A 94-byte LERC blob can otherwise
1287+
request 64 MiB of host memory because LERC compresses constant
1288+
blocks at >700,000:1; without this pre-check the post-decode size
1289+
check in :func:`_decode_strip_or_tile` fires only after the bomb
1290+
has already been materialised.
12011291
"""
12021292
if not LERC_AVAILABLE:
12031293
raise ImportError(
12041294
"lerc is required to read LERC-compressed TIFFs. "
12051295
"Install it with: pip install lerc")
1296+
_check_lerc_bomb(data, expected_size)
12061297
result = _lerc.decode(data)
12071298
# lerc.decode returns (result_code, data_array, valid_mask, ...)
12081299
if result[0] != 0:
@@ -1355,13 +1446,17 @@ def decompress(data, compression: int, expected_size: int = 0,
13551446
zstd_decompress(data, expected_size), dtype=np.uint8)
13561447
elif compression == COMPRESSION_JPEG2000:
13571448
return np.frombuffer(
1358-
jpeg2000_decompress(data, width, height, samples), dtype=np.uint8)
1449+
jpeg2000_decompress(data, width, height, samples,
1450+
expected_size=expected_size),
1451+
dtype=np.uint8)
13591452
elif compression == COMPRESSION_LZ4:
13601453
return np.frombuffer(
13611454
lz4_decompress(data, expected_size), dtype=np.uint8)
13621455
elif compression == COMPRESSION_LERC:
13631456
return np.frombuffer(
1364-
lerc_decompress(data, width, height, samples), dtype=np.uint8)
1457+
lerc_decompress(data, width, height, samples,
1458+
expected_size=expected_size),
1459+
dtype=np.uint8)
13651460
else:
13661461
raise ValueError(f"Unsupported compression type: {compression}")
13671462

xrspatial/geotiff/_gpu_decode.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,7 +1933,9 @@ def gpu_decode_tiles(
19331933
for i, tile in enumerate(compressed_tiles):
19341934
start = i * tile_bytes
19351935
chunk = np.frombuffer(
1936-
jpeg2000_decompress(tile, tile_width, tile_height, samples),
1936+
jpeg2000_decompress(
1937+
tile, tile_width, tile_height, samples,
1938+
expected_size=tile_bytes),
19371939
dtype=np.uint8)
19381940
raw_host[start:start + min(len(chunk), tile_bytes)] = \
19391941
chunk[:tile_bytes] if len(chunk) >= tile_bytes else \
@@ -1953,7 +1955,8 @@ def gpu_decode_tiles(
19531955
any_lerc_mask = False
19541956
for i, tile in enumerate(compressed_tiles):
19551957
start = i * tile_bytes
1956-
decoded_bytes, valid_mask = lerc_decompress_with_mask(tile)
1958+
decoded_bytes, valid_mask = lerc_decompress_with_mask(
1959+
tile, expected_size=tile_bytes)
19571960
chunk = np.frombuffer(decoded_bytes, dtype=np.uint8)
19581961
raw_host[start:start + min(len(chunk), tile_bytes)] = \
19591962
chunk[:tile_bytes] if len(chunk) >= tile_bytes else \

xrspatial/geotiff/_reader.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,11 @@ def _decode_strip_or_tile(data_slice, compression, width, height, samples,
776776
# valid-mask which the generic decompress() dispatcher discards.
777777
# We capture it here so masked pixels can be restored to nodata
778778
# below, instead of leaking LERC's zero fill into the output.
779-
decoded_bytes, lerc_mask = lerc_decompress_with_mask(data_slice)
779+
# Forward ``expected`` so the wrapper rejects bombs at the
780+
# blob-header level rather than after the full buffer is
781+
# materialised (issue #1625).
782+
decoded_bytes, lerc_mask = lerc_decompress_with_mask(
783+
data_slice, expected_size=expected)
780784
chunk = np.frombuffer(decoded_bytes, dtype=np.uint8)
781785
else:
782786
chunk = decompress(data_slice, compression, expected,

0 commit comments

Comments
 (0)