Skip to content
24 changes: 23 additions & 1 deletion backend/geolibre_server/geolibre_server/app/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ def shapefile_field_warnings(column_names):
_JOBS_LOCK = threading.Lock()
_RUNTIME_SETUP_LOCK = threading.Lock()
MAX_RETAINED_JOBS = 100
# Concurrent pending/running conversion (and raster) jobs. Finished jobs are
# retained up to MAX_RETAINED_JOBS; in-flight work is refused with HTTP 429 once
# this cap is hit so a burst of /run calls cannot spawn unbounded subprocesses.
MAX_IN_FLIGHT_JOBS = 8


class VectorToVectorRequest(BaseModel):
Expand Down Expand Up @@ -1033,6 +1037,11 @@ def _append_job_message(job_id: str, message: str) -> None:
)


def _count_in_flight_jobs_locked() -> int:
"""Return how many jobs are pending or running. Caller must hold ``_JOBS_LOCK``."""
return sum(1 for job in _JOBS.values() if job.status in {"pending", "running"})


def _evict_finished_jobs_locked() -> None:
"""Drop the oldest finished jobs once the retention cap is exceeded.

Expand Down Expand Up @@ -1122,7 +1131,15 @@ def _run_conversion_job(
outputs={output_name: {"path": output_path}} if output_path else {},
)
except Exception as exc:
_job_update(job_id, status="failed", error=str(exc))
# Mirror Whitebox: log the raw failure server-side and surface only a
# generic message. DuckDB/GDAL stderr often embeds absolute paths that
# must not reach the browser-proxied /jobs/{id} response.
logger.warning("Conversion job %s failed: %s", job_id, exc, exc_info=True)
_job_update(
job_id,
status="failed",
error="Conversion failed. See the sidecar logs for details.",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
# Remove a partial output so a retry starts clean and stale bytes do not
# confuse downstream tools.
output_path = params.get("output_path")
Expand All @@ -1148,6 +1165,11 @@ def _start_job(
job_id = str(uuid.uuid4())
now = _utc_now()
with _JOBS_LOCK:
if _count_in_flight_jobs_locked() >= MAX_IN_FLIGHT_JOBS:
raise HTTPException(
status_code=429,
detail="Too many conversion jobs in progress; try again shortly.",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
_JOBS[job_id] = JobState(
id=job_id,
status="pending",
Expand Down
28 changes: 28 additions & 0 deletions backend/geolibre_server/geolibre_server/app/raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ def model(hh):


_RASTER_CALC_SCRIPT = """
import ast
import json, re, sys

import numpy as np
Expand Down Expand Up @@ -1024,6 +1025,33 @@ def load_bands(path, letter, namespace, base):
"e": np.e,
}
namespace.update(safe_funcs)
# Band arrays are full ndarrays, so attribute access (``A.tofile(...)``,
# ``A.dump(...)``) would still write files even with an empty ``__builtins__``
# and no bare ``np``. Parse the expression and reject attribute access plus any
# call that is not one of the curated safe functions before ``eval``.
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as exc:
raise SystemExit(f"Failed to evaluate expression: {exc}") from exc
allowed_calls = set(safe_funcs)
for node in ast.walk(tree):
if isinstance(node, ast.Attribute):
raise SystemExit(
"Expression may not access attributes "
"(band math only allows curated functions and operators)"
)
if isinstance(node, ast.Call):
# ``np.where(...)`` / ``A.tofile(...)`` parse as Attribute-backed calls;
# reject them the same way as bare attribute access so ndarray file I/O
# and the withheld ``np`` module stay unreachable.
if isinstance(node.func, ast.Attribute):
raise SystemExit(
"Expression may not access attributes "
"(band math only allows curated functions and operators)"
)
if not isinstance(node.func, ast.Name) or node.func.id not in allowed_calls:
name = node.func.id if isinstance(node.func, ast.Name) else type(node.func).__name__
raise SystemExit(f"Call to '{name}' is not allowed in band math")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
with np.errstate(all="ignore"):
result = eval(expression, {"__builtins__": {}}, namespace)
Expand Down
38 changes: 38 additions & 0 deletions backend/geolibre_server/geolibre_server/app/whitebox.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from geolibre_server.vector_ops import MAX_FEATURES as MAX_LAYER_FEATURES

from . import conversion
from .runtime import (
RUNTIME_CATALOG_TIMEOUT_SECS,
Expand Down Expand Up @@ -88,6 +90,10 @@ class WhiteboxRunRequest(BaseModel):
_JOBS_LOCK = threading.Lock()
_RUNTIME_SETUP_LOCK = threading.Lock()
MAX_RETAINED_JOBS = 100
# Concurrent pending/running Whitebox jobs. Finished jobs are retained up to
# MAX_RETAINED_JOBS; in-flight work is refused with HTTP 429 once this cap is
# hit so a burst of /run calls cannot spawn unbounded tool sessions.
MAX_IN_FLIGHT_JOBS = 8


def _check_python_import(python_executable: str) -> None:
Expand Down Expand Up @@ -701,10 +707,19 @@ def _write_layer_input(param_name: str, layer: dict[str, Any], temp_paths: list[

Returns:
Path to the materialized input file.

Raises:
ValueError: When the layer payload is not GeoJSON, or exceeds the
shared feature cap used by vector/PostGIS/Sedona paths.
"""
geojson = layer.get("geojson")
if not isinstance(geojson, dict):
raise ValueError(f"Layer input for {param_name} does not contain GeoJSON.")
features = geojson.get("features") or []
if isinstance(features, list) and len(features) > MAX_LAYER_FEATURES:
raise ValueError(
f"Layer input for {param_name} exceeds the {MAX_LAYER_FEATURES}-feature limit"
)
folder = Path(tempfile.mkdtemp(prefix="geolibre-whitebox-input-"))
temp_paths.append(folder)
path = folder / f"{_safe_output_stem('input', param_name)}.geojson"
Expand Down Expand Up @@ -1041,15 +1056,38 @@ def _evict_finished_jobs_locked() -> None:
_JOBS.pop(job_id, None)


def _count_in_flight_jobs_locked() -> int:
"""Return how many jobs are pending or running. Caller must hold ``_JOBS_LOCK``."""
return sum(1 for job in _JOBS.values() if job.status in {"pending", "running"})


@router.post("/run")
def whitebox_run(request: WhiteboxRunRequest):
"""Start a background Whitebox tool run."""
tool_id = request.tool_id.strip()
if not tool_id:
raise HTTPException(status_code=400, detail="tool_id is required")
# Reject oversized embedded layers before enqueueing work, matching the
# 413 vector/PostGIS/Sedona feature cap (defense-in-depth also lives in
# ``_write_layer_input``).
for name, layer in request.layer_inputs.items():
geojson = layer.get("geojson") if isinstance(layer, dict) else None
if not isinstance(geojson, dict):
continue
features = geojson.get("features") or []
if isinstance(features, list) and len(features) > MAX_LAYER_FEATURES:
raise HTTPException(
status_code=413,
detail=(f"Layer input for {name} exceeds the {MAX_LAYER_FEATURES}-feature limit"),
)
job_id = str(uuid.uuid4())
now = _utc_now()
with _JOBS_LOCK:
if _count_in_flight_jobs_locked() >= MAX_IN_FLIGHT_JOBS:
raise HTTPException(
status_code=429,
detail="Too many Whitebox jobs in progress; try again shortly.",
)
_JOBS[job_id] = JobState(
id=job_id,
status="pending",
Expand Down
7 changes: 6 additions & 1 deletion backend/geolibre_server/geolibre_server/sedona_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def run_sql(sql: str, layers: Optional[list[dict]] = None) -> dict:
``rows`` and as GeoJSON in ``geojson``.

Raises:
SqlInputTooLarge: A layer exceeds :data:`MAX_FEATURES`.
SqlInputTooLarge: A layer or the query result exceeds :data:`MAX_FEATURES`.
ValueError: Invalid input.
Exception: Whatever SedonaDB raises for an invalid SQL statement.
"""
Expand Down Expand Up @@ -151,6 +151,11 @@ def run_sql(sql: str, layers: Optional[list[dict]] = None) -> dict:
# to_pandas() returns a GeoDataFrame when the result has a geometry
# column, otherwise a plain DataFrame.
frame = result.to_pandas()
if len(frame) > MAX_FEATURES:
# Input registration already caps each layer, but a query can still
# expand rows (cross joins, generate_series, etc.). Bound the
# response the same way vector/PostGIS paths bound payloads.
raise SqlInputTooLarge(f"Query result exceeds the {MAX_FEATURES}-feature limit")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
columns = [str(column) for column in frame.columns]

geometry_column: Optional[str] = None
Expand Down
51 changes: 51 additions & 0 deletions backend/geolibre_server/tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,54 @@ def test_evict_finished_jobs_never_drops_running(monkeypatch) -> None:
_evict_finished_jobs_locked()
# Excess is 2, but only the one finished job is eligible for eviction.
assert set(jobs) == {"old_running", "pending"}


def test_start_job_rejects_when_in_flight_cap_reached(monkeypatch) -> None:
"""A new conversion job is refused with 429 once in-flight work is at the cap."""
monkeypatch.setattr(conversion, "MAX_IN_FLIGHT_JOBS", 1)
monkeypatch.setattr(
conversion,
"_JOBS",
{"busy": _job("busy", "running", "2026-01-01T00:00:00+00:00")},
)

with pytest.raises(HTTPException) as exc:
conversion._start_job("vector-to-geoparquet", "pass", {}, "output")
assert exc.value.status_code == 429
assert "Too many conversion jobs" in str(exc.value.detail)


def test_conversion_job_does_not_leak_error(monkeypatch, tmp_path: Path) -> None:
"""Failed conversion jobs store a generic error, never the raw exception text."""
job_id = "test-conversion-leak"
now = conversion._utc_now()
out = tmp_path / "out.parquet"
with conversion._JOBS_LOCK:
conversion._JOBS[job_id] = conversion.JobState(
id=job_id,
status="pending",
tool_id="vector-to-geoparquet",
created_at=now,
updated_at=now,
)

secret = "/secret/path/to/duckdb: boom traceback leak"

def _boom(*_args, **_kwargs):
raise RuntimeError(secret)

monkeypatch.setattr(conversion, "_runtime_python", _boom)
try:
conversion._run_conversion_job(
job_id,
"pass",
{"output_path": str(out)},
"output",
)
job = conversion._JOBS[job_id]
assert job.status == "failed"
assert job.error == "Conversion failed. See the sidecar logs for details."
assert secret not in (job.error or "")
finally:
with conversion._JOBS_LOCK:
conversion._JOBS.pop(job_id, None)
37 changes: 36 additions & 1 deletion backend/geolibre_server/tests/test_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,12 @@ def test_raster_calculator_blocks_numpy_io(tmp_path: Path) -> None:
text=True,
)
assert completed.returncode != 0
assert "Failed to evaluate expression" in (completed.stdout + completed.stderr)
combined = completed.stdout + completed.stderr
assert (
"Failed to evaluate expression" in combined
or "may not access attributes" in combined
Comment thread
giswqs marked this conversation as resolved.
Outdated
or "not allowed in band math" in combined
)
assert not out.exists()


Expand Down Expand Up @@ -1024,3 +1029,33 @@ def test_focal_std_zero_on_flat_input(tmp_path: Path) -> None:
with rasterio.open(out) as ds:
got = ds.read(1)
assert np.allclose(got, 0.0, atol=1e-4)


@requires_rasterio
def test_raster_calculator_blocks_ndarray_tofile(tmp_path: Path) -> None:
"""Ndarray file I/O via attribute access must not bypass the path allowlist."""
src = _write_dem(tmp_path / "dem.tif")
out = tmp_path / "calc.tif"
evil = tmp_path / "evil.bin"
completed = subprocess.run(
[
sys.executable,
"-c",
_RASTER_TOOL_SCRIPTS["raster-calc"],
json.dumps(
{
"input_path": str(src),
"output_path": str(out),
"expression": f"(A.tofile({str(evil)!r}), A)[1]",
}
),
],
check=False,
capture_output=True,
text=True,
)
assert completed.returncode != 0
combined = completed.stdout + completed.stderr
assert "may not access attributes" in combined
assert not evil.exists()
assert not out.exists()
45 changes: 45 additions & 0 deletions backend/geolibre_server/tests/test_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,48 @@ def test_run_rejects_oversized_layer(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(HTTPException) as exc:
sql_run(SqlRunRequest(sql="SELECT 1", layers=[{"name": "big", "geojson": big}]))
assert exc.value.status_code == 413


@requires_sedona
def test_run_rejects_oversized_result(monkeypatch: pytest.MonkeyPatch) -> None:
"""Query results that expand past MAX_FEATURES are refused with 413."""
monkeypatch.setattr(sedona_ops, "MAX_FEATURES", 1)

class _FakeFrame:
def __len__(self) -> int:
return 2

@property
def columns(self):
return ["n"]

def to_dict(self, orient: str): # noqa: ARG002
return [{"n": 1}, {"n": 2}]

class _FakeResult:
def to_pandas(self):
return _FakeFrame()

class _FakeConnection:
def create_data_frame(self, gdf): # noqa: ANN001, ARG002
class _View:
def to_view(self, name: str) -> None: # noqa: ARG002
return None

return _View()

def sql(self, statement: str): # noqa: ARG002
return _FakeResult()

def close(self) -> None:
return None

monkeypatch.setattr(
sedona_ops,
"_import_sedona",
lambda: type("M", (), {"connect": staticmethod(lambda: _FakeConnection())})(),
)
with pytest.raises(HTTPException) as exc:
sql_run(SqlRunRequest(sql="SELECT 1 AS n UNION ALL SELECT 2 AS n"))
assert exc.value.status_code == 413
assert "Query result exceeds" in str(exc.value.detail)
44 changes: 44 additions & 0 deletions backend/geolibre_server/tests/test_whitebox_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,47 @@ def _boom(*args, **kwargs):
finally:
with whitebox._JOBS_LOCK:
whitebox._JOBS.pop(job_id, None)


def test_run_rejects_when_in_flight_cap_reached(monkeypatch):
"""A new Whitebox job is refused with 429 once in-flight work is at the cap."""
monkeypatch.setattr(whitebox, "MAX_IN_FLIGHT_JOBS", 1)
now = whitebox._utc_now()
with whitebox._JOBS_LOCK:
whitebox._JOBS.clear()
whitebox._JOBS["busy"] = whitebox.JobState(
id="busy",
status="running",
tool_id="noop",
created_at=now,
updated_at=now,
)
try:
with pytest.raises(HTTPException) as excinfo:
whitebox.whitebox_run(whitebox.WhiteboxRunRequest(tool_id="noop"))
assert excinfo.value.status_code == 429
assert "Too many Whitebox jobs" in str(excinfo.value.detail)
finally:
with whitebox._JOBS_LOCK:
whitebox._JOBS.clear()


def test_run_rejects_oversized_layer_input(monkeypatch):
"""Embedded GeoJSON layer inputs honor the shared MAX_FEATURES cap with 413."""
monkeypatch.setattr(whitebox, "MAX_LAYER_FEATURES", 1)
big = {
"type": "FeatureCollection",
"features": [
{"type": "Feature", "properties": {}, "geometry": None},
{"type": "Feature", "properties": {}, "geometry": None},
],
}
with pytest.raises(HTTPException) as excinfo:
whitebox.whitebox_run(
whitebox.WhiteboxRunRequest(
tool_id="noop",
layer_inputs={"input": {"geojson": big}},
)
)
assert excinfo.value.status_code == 413
assert "feature limit" in str(excinfo.value.detail)
Loading