Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ name = "eo-api"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"titiler-core>=1.2.0",
"titiler-core>=2.0.1",
"titiler-xarray>=2.0.1",
"uvicorn>=0.41.0",
"python-dotenv>=1.0.1",
"pygeoapi>=0.22.0",
Expand Down
2 changes: 2 additions & 0 deletions src/eo_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from eo_api.ingestions import routes as ingestion_routes
from eo_api.pygeoapi_app import mount_pygeoapi
from eo_api.system import routes as system_routes
from eo_api.tiles import titiler_routes

Comment thread
turban marked this conversation as resolved.
app = FastAPI()

Expand All @@ -27,5 +28,6 @@
app.include_router(ingestion_routes.ingestions_router, prefix="/ingestions", tags=["Ingestions"])
app.include_router(ingestion_routes.zarr_router, prefix="/zarr", tags=["Zarr"])
app.include_router(ingestion_routes.sync_router, prefix="/sync", tags=["Sync"])
app.include_router(titiler_routes.router, prefix='/titiler', tags=["TiTiler"])
Comment on lines 28 to +31
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new public API surface under /titiler but there are no tests covering router registration or basic request/response behavior (including expected failures for invalid/malicious url inputs). Add FastAPI TestClient coverage for at least one happy-path tile request (can be skipped/marked if test data isn’t available) and one validation/rejection case.

Copilot generated this review using guidance from repository custom instructions.
Comment thread
turban marked this conversation as resolved.

mount_pygeoapi(app)
5 changes: 5 additions & 0 deletions src/eo_api/tiles/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Tiles package."""

from . import titiler as titiler_routes

__all__ = ["titiler_routes"]
10 changes: 10 additions & 0 deletions src/eo_api/tiles/titiler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Xarray-backed TiTiler router configuration"""

from titiler.xarray.factory import TilerFactory
from titiler.xarray.io import Reader
from titiler.xarray.extensions import VariablesExtension

router = TilerFactory(
reader=Reader,
Comment on lines +3 to +8
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mounting TiTiler with titiler.xarray.io.Reader and exposing the upstream url/path query parameter (as shown in the PR examples) can allow arbitrary remote fetches (SSRF) and/or local file reads depending on the Reader’s supported schemes. Consider wrapping/replacing the Reader with one that enforces an allowlist (e.g., only local dataset roots or specific URL schemes/hosts), and explicitly rejecting file:// and private-network destinations if HTTP(S) is allowed.

Suggested change
from titiler.xarray.factory import TilerFactory
from titiler.xarray.io import Reader
from titiler.xarray.extensions import VariablesExtension
router = TilerFactory(
reader=Reader,
import ipaddress
import os
import socket
from pathlib import Path
from urllib.parse import urlparse
from fastapi import HTTPException
from titiler.xarray.factory import TilerFactory
from titiler.xarray.io import Reader
from titiler.xarray.extensions import VariablesExtension
_ALLOWED_DATASET_ROOTS_ENV = "EO_API_TITILER_ALLOWED_DATASET_ROOTS"
_ALLOWED_REMOTE_HOSTS_ENV = "EO_API_TITILER_ALLOWED_REMOTE_HOSTS"
_ALLOWED_REMOTE_SCHEMES_ENV = "EO_API_TITILER_ALLOWED_REMOTE_SCHEMES"
def _get_env_list(name: str) -> list[str]:
value = os.getenv(name, "")
return [item.strip() for item in value.split(",") if item.strip()]
def _path_is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _hostname_targets_private_network(hostname: str) -> bool:
try:
parsed_ip = ipaddress.ip_address(hostname)
return (
parsed_ip.is_private
or parsed_ip.is_loopback
or parsed_ip.is_link_local
or parsed_ip.is_multicast
or parsed_ip.is_reserved
or parsed_ip.is_unspecified
)
except ValueError:
pass
try:
address_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return True
for entry in address_info:
candidate_ip = ipaddress.ip_address(entry[4][0])
if (
candidate_ip.is_private
or candidate_ip.is_loopback
or candidate_ip.is_link_local
or candidate_ip.is_multicast
or candidate_ip.is_reserved
or candidate_ip.is_unspecified
):
return True
return False
def _validate_source(source: str) -> None:
parsed = urlparse(source)
if parsed.scheme:
scheme = parsed.scheme.lower()
if scheme == "file":
raise HTTPException(
status_code=400,
detail="file:// sources are not allowed for tile requests.",
)
if scheme not in {"http", "https"}:
raise HTTPException(
status_code=400,
detail=f"Unsupported source scheme '{scheme}'.",
)
allowed_schemes = {item.lower() for item in _get_env_list(_ALLOWED_REMOTE_SCHEMES_ENV)}
if not allowed_schemes or scheme not in allowed_schemes:
raise HTTPException(
status_code=400,
detail="Remote sources are not allowed for this deployment.",
)
hostname = parsed.hostname
if not hostname:
raise HTTPException(
status_code=400,
detail="Remote source URL must include a hostname.",
)
allowed_hosts = {item.lower() for item in _get_env_list(_ALLOWED_REMOTE_HOSTS_ENV)}
if not allowed_hosts or hostname.lower() not in allowed_hosts:
raise HTTPException(
status_code=400,
detail="Remote source host is not allowed.",
)
if _hostname_targets_private_network(hostname):
raise HTTPException(
status_code=400,
detail="Remote source resolves to a private or otherwise disallowed network address.",
)
return
allowed_roots = [
Path(item).expanduser().resolve(strict=False)
for item in _get_env_list(_ALLOWED_DATASET_ROOTS_ENV)
]
if not allowed_roots:
raise HTTPException(
status_code=400,
detail="Local sources are not allowed for this deployment.",
)
resolved_source = Path(source).expanduser().resolve(strict=False)
if not any(_path_is_within(resolved_source, root) for root in allowed_roots):
raise HTTPException(
status_code=400,
detail="Local source path is outside the configured allowed dataset roots.",
)
class RestrictedReader(Reader):
def __init__(self, *args, **kwargs) -> None:
source = kwargs.get("src_path") or kwargs.get("url") or (args[0] if args else None)
if not isinstance(source, str) or not source:
raise HTTPException(
status_code=400,
detail="A valid source path or URL is required.",
)
_validate_source(source)
super().__init__(*args, **kwargs)
router = TilerFactory(
reader=RestrictedReader,

Copilot uses AI. Check for mistakes.
extensions=[VariablesExtension()]
).router
Loading
Loading