Skip to content

Commit 1e314f8

Browse files
authored
GeoTIFF: case-insensitive HTTP(S) scheme routing for SSRF protection (#2332) (#2337)
1 parent 3e2d2ad commit 1e314f8

7 files changed

Lines changed: 390 additions & 39 deletions

File tree

xrspatial/geotiff/_backends/dask.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,13 @@ def read_geotiff_dask(source: str, *,
194194
# and ``_CloudSource`` satisfies that contract. Going through it
195195
# bounds metadata reads to ``MAX_HTTP_HEADER_BYTES`` instead of
196196
# fetching the whole remote object up front. See PR #1755 review.
197-
from .._reader import _is_fsspec_uri, _is_http_url
198-
is_http = _is_http_url(source)
197+
# Local imports: backend modules avoid eager-importing the reader /
198+
# sources layer at module load so the package can be imported without
199+
# urllib3 in environments that only consume the dask path.
200+
# Issues #2323 / #2332.
201+
from .._reader import _is_fsspec_uri
202+
from .._sources import _is_http_source
203+
is_http = _is_http_source(source)
199204
is_fsspec = isinstance(source, str) and _is_fsspec_uri(source)
200205
http_meta = None
201206
http_meta_key = None
@@ -573,8 +578,8 @@ def _read(http_meta):
573578
# fsspec-addressable remotes (s3://, gs://, az://, memory://, ...).
574579
# Both source classes expose ``read_range``, which is all
575580
# ``_fetch_decode_cog_http_tiles`` needs.
576-
from .._reader import _is_http_url as _ihu
577-
_is_http_src = _ihu(source)
581+
from .._sources import _is_http_source as _ihs
582+
_is_http_src = _ihs(source)
578583
_is_fsspec_src = False
579584
if http_meta is not None and isinstance(source, str) and \
580585
not _is_http_src:

xrspatial/geotiff/_backends/gpu.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ def read_geotiff_gpu(source: str, *,
316316
from .._header import parse_all_ifds, parse_header, select_overview_ifd, validate_tile_layout
317317
from .._reader import (MAX_PIXELS_DEFAULT, _check_dimensions, _FileSource, _is_fsspec_uri,
318318
_max_tile_bytes_from_env, _resolve_masked_fill)
319+
from .._sources import _is_http_source
319320

320321
# ``source`` is already coerced above (before the dispatch
321322
# validator); no need to re-coerce here.
@@ -346,9 +347,8 @@ def read_geotiff_gpu(source: str, *,
346347
# whole image either way for the eager path; the trade-off is a CPU
347348
# decode instead of nvCOMP-on-GPU. Callers who want bounded GPU
348349
# memory should pass ``chunks=...``.
349-
from .._reader import _is_http_url
350350
if isinstance(source, str) and (
351-
_is_http_url(source)
351+
_is_http_source(source)
352352
or _is_fsspec_uri(source)):
353353
return _read_geotiff_gpu_eager_via_cpu(
354354
source, dtype=dtype, window=window,
@@ -1075,9 +1075,9 @@ def _gds_chunk_path_available(source, ifd, has_sparse_tile, orientation):
10751075
# import failure would silently let an HTTP URL into the kvikio
10761076
# branch (which opens the path as a local file and panics). The
10771077
# canonical case-insensitive helper is a sibling module, so the
1078-
# import is safe at module load time (#2323).
1079-
from .._sources import _is_http_url
1080-
if _is_http_url(source):
1078+
# import is safe at module load time. Issues #2323 / #2332.
1079+
from .._sources import _is_http_source
1080+
if _is_http_source(source):
10811081
return False
10821082
try:
10831083
from .._reader import _is_fsspec_uri

xrspatial/geotiff/_reader.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@
9393
_BytesIOSource, _CloudSource, _coerce_path, _FileSource, _get_http_pool,
9494
_get_pinned_conn_classes, _http_allow_private_hosts, _http_connect_timeout,
9595
_http_read_timeout, _http_timeout_from_env, _HTTPSource, _ip_is_private,
96-
_is_file_like, _is_fsspec_uri, _is_http_url, _make_pinned_pool,
96+
_is_file_like, _is_fsspec_uri, _is_http_source, _is_http_url,
97+
_make_pinned_pool,
9798
_max_coalesced_range_bytes_from_env, _max_tile_bytes_from_env, _mmap_cache,
9899
_mmap_cache_size_from_env, _MmapCache, _open_source,
99100
_resolve_max_cloud_bytes, _validate_http_url, coalesce_ranges,
@@ -142,7 +143,7 @@ def _read_to_array(source, *, window=None, overview_level: int | None = None,
142143
(np.ndarray, GeoInfo) tuple
143144
"""
144145
source = _coerce_path(source)
145-
if _is_http_url(source):
146+
if _is_http_source(source):
146147
return _read_cog_http(source, overview_level=overview_level, band=band,
147148
max_pixels=max_pixels, window=window,
148149
allow_rotated=allow_rotated)

xrspatial/geotiff/_sidecar.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
# ``_reader`` imports ``_sidecar`` lazily (inside functions), so this
2727
# top-level import does not form a cycle at module load time.
2828
from ._reader import _is_fsspec_uri
29-
# Canonical case-insensitive http(s) check (#2323). Reused via the
30-
# wrapper ``_is_http_url`` below so existing imports keep working.
31-
from ._sources import _is_http_url as _canonical_is_http_url
29+
from ._sources import _is_http_source
3230

3331
#: Type of the bytes-like buffer a sidecar carries: an mmap for local
3432
#: files, bytes for HTTP / fsspec downloads. Narrowed from ``object``
@@ -46,9 +44,13 @@ class SidecarOverviews(NamedTuple):
4644

4745

4846
def _is_http_url(source: str) -> bool:
49-
# Delegate to the canonical case-insensitive check so uppercase
50-
# ``HTTP://`` URLs cannot dodge SSRF validation (issue #2323).
51-
return _canonical_is_http_url(source)
47+
"""Case-insensitive HTTP(S) scheme test for sidecar routing.
48+
49+
Delegates to :func:`xrspatial.geotiff._sources._is_http_source` so
50+
the SSRF-relevant routing decision matches the rest of the package
51+
(issues #2323 / #2332).
52+
"""
53+
return _is_http_source(source)
5254

5355

5456
def find_sidecar(source) -> str | None:

xrspatial/geotiff/_sources.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,32 +1452,45 @@ def close(self):
14521452
_CLOUD_SCHEMES = ('s3://', 'gs://', 'az://', 'abfs://')
14531453

14541454

1455-
def _is_http_url(path) -> bool:
1456-
"""Return True if *path* is an ``http://`` or ``https://`` URL.
1457-
1458-
Case-insensitive: URL schemes are case-insensitive per RFC 3986, so an
1459-
uppercase ``HTTP://`` or mixed-case ``Http://`` must dispatch to the
1460-
SSRF-validating :class:`_HTTPSource`, not to the fsspec branch. See
1461-
issue #2323.
1455+
def _is_http_source(source) -> bool:
1456+
"""Return True if ``source`` is an HTTP(S) URL, case-insensitively.
1457+
1458+
Centralized so every routing call site in ``xrspatial/geotiff/``
1459+
classifies the scheme the same way. Before this helper existed,
1460+
each call site did ``source.startswith(('http://', 'https://'))``,
1461+
which is case-sensitive and let ``HTTP://example.internal/...``
1462+
(uppercase) slip past :class:`_HTTPSource` and the SSRF allow-list
1463+
in :func:`_validate_http_url`. Per RFC 3986 section 3.1 URI schemes
1464+
are case-insensitive, so any uppercase / mixed-case variant has to
1465+
route through the same validator as ``http`` / ``https``.
1466+
1467+
Non-string inputs (``None``, ``bytes``, ``os.PathLike``, file-like
1468+
objects) return ``False`` so callers can drop the surrounding
1469+
``isinstance(_, str)`` check where they want to. Issues #2323 / #2332.
14621470
"""
1463-
if not isinstance(path, str):
1464-
return False
1465-
try:
1466-
scheme = urlparse(path).scheme
1467-
except (ValueError, TypeError):
1471+
if not isinstance(source, str) or not source:
14681472
return False
1469-
return scheme.lower() in ('http', 'https')
1473+
# ``urlparse`` strips off the scheme cleanly even for unusual inputs
1474+
# (e.g. ``HTTP:`` with no ``//``) and avoids the prefix-tuple trap.
1475+
return urlparse(source).scheme.lower() in ('http', 'https')
1476+
1477+
1478+
# Back-compat alias: earlier patches (#2323) shipped this same helper under
1479+
# the name ``_is_http_url`` and downstream tests / re-exports still use that
1480+
# name. Keep the alias so importers and the regression tests stay green.
1481+
_is_http_url = _is_http_source
14701482

14711483

14721484
def _is_fsspec_uri(path: str) -> bool:
14731485
"""Check if a path is a fsspec-compatible URI (not http/https/local).
14741486
14751487
Excludes http(s) case-insensitively so uppercase URLs cannot dodge the
1476-
SSRF allow-list and pinned DNS in :class:`_HTTPSource` (issue #2323).
1488+
SSRF allow-list and pinned DNS in :class:`_HTTPSource` (issues #2323 /
1489+
#2332).
14771490
"""
14781491
if not isinstance(path, str):
14791492
return False
1480-
if _is_http_url(path):
1493+
if _is_http_source(path):
14811494
return False
14821495
return '://' in path
14831496

@@ -1658,7 +1671,7 @@ def _open_source(source):
16581671
raise TypeError(
16591672
f"source must be a str path/URL or a binary file-like object "
16601673
f"with read+seek methods, got {type(source).__name__}")
1661-
if _is_http_url(source):
1674+
if _is_http_source(source):
16621675
return _HTTPSource(source)
16631676
if _is_fsspec_uri(source):
16641677
return _CloudSource(source)

xrspatial/geotiff/_writer.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,11 @@
6161
# (``_write``, ``_write_streaming``) and external importers (the
6262
# ``_writers`` subpackage, tests, ``_gpu_decode``) keep using the
6363
# ``xrspatial.geotiff._writer`` import path.
64+
from ._sources import _is_http_source
6465
from ._write_layout import (BO, _assemble_cog_layout, _assemble_standard_layout, # noqa: F401
6566
_assemble_tiff, _build_ifd, _compute_classic_ifd_overhead,
6667
_float_to_rational, _pack_tag_value, _promote_offsets_to_long8,
6768
_serialize_tag_value, _should_use_bigtiff_streaming)
68-
# Canonical case-insensitive http(s) check (#2323) so the writer-side
69-
# fsspec gate cannot be tricked by uppercase URLs.
70-
from ._sources import _is_http_url as _is_http_url_canonical
7169

7270
# Tag IDs the writer must never accept from ``extra_tags``. NewSubfileType
7371
# (254) is a per-IFD status flag the writer emits on its own for overview
@@ -1243,12 +1241,16 @@ def _write_streaming(dask_data, path: str, *,
12431241
def _is_fsspec_uri(path) -> bool:
12441242
"""Check if a path is a fsspec-compatible URI (string only).
12451243
1246-
Excludes http(s) case-insensitively so uppercase URLs are not routed
1247-
through fsspec on the writer side (issue #2323).
1244+
HTTP(S) URLs are deliberately excluded here so the writer can raise
1245+
a typed "writes not supported over HTTP" error instead of handing
1246+
the URL to fsspec. Uses :func:`_sources._is_http_source` so the
1247+
HTTP detection is case-insensitive (RFC 3986); without that, an
1248+
uppercase ``HTTP://...`` slipped past this check and into fsspec.
1249+
Issues #2323 / #2332.
12481250
"""
12491251
if not isinstance(path, str):
12501252
return False
1251-
if _is_http_url_canonical(path):
1253+
if _is_http_source(path):
12521254
return False
12531255
return '://' in path
12541256

@@ -1280,6 +1282,16 @@ def _write_bytes(file_bytes: bytes | bytearray, path) -> None:
12801282
path.write(file_bytes)
12811283
return
12821284

1285+
# Reject HTTP(S) write targets with a typed error before the local
1286+
# file path tries to treat the URL as a filename. ``_is_http_source``
1287+
# is case-insensitive so ``HTTP://...`` reports the same friendly
1288+
# error as ``http://...``. Issue #2332.
1289+
if isinstance(path, str) and _is_http_source(path):
1290+
raise NotImplementedError(
1291+
f"Writes are not supported over HTTP(S). Got {path!r}. "
1292+
"Write to a local path or an fsspec-supported cloud URL "
1293+
"(s3://, gs://, az://, ...) instead.")
1294+
12831295
if _is_fsspec_uri(path):
12841296
try:
12851297
import fsspec

0 commit comments

Comments
 (0)