thermograph/backend/tests/web
Emi Griffith 63e1967bbb lake: gate /query and stop it reading outside the two lake views
The SELECT guard was a denylist of DDL/DML verbs. DuckDB's file readers and
setting introspection are plain SELECTs, so it passed all of these:

  SELECT * FROM read_csv_auto('/etc/passwd')     -> 200, file contents
  SELECT * FROM glob('/etc/*')                   -> 200, directory listing
  SELECT current_setting('s3_secret_access_key') -> 200, the bucket credentials

The last one is the worst: bucket mode installed the S3 credentials with
SET s3_secret_access_key, and settings are readable back out. So one
unauthenticated POST returned the ERA5 bucket key in plaintext -- precisely what
the module docstring says this service exists to avoid ("without shipping S3
credentials to every web task"). /query had no authentication of any kind, while
the comparable /internal/* router has gated its whole surface since it landed.

Three changes, outermost first:

- /query now requires the shared internal token, reusing internal_routes'
  dependency. Nothing calls it: the only in-repo caller of this service is
  era5lake.py hitting /history, and Centralis reaches the lake through its own
  DuckDB rather than this endpoint. /history is deliberately left open for now --
  gating it means threading the token through the web->lake hot path.
- Credentials are installed as a DuckDB SECRET instead of SET s3_*. Secret values
  are opaque: current_setting() returns NULL and duckdb_secrets() lists the name
  and scope but no key material.
- Bucket mode disables LocalFileSystem after the views are created (they resolve
  lazily, so ordering matters) and locks the configuration. Local mode cannot do
  this -- its views read local parquet -- so the function denylist stays as
  defence in depth for dev and tests.

The new guard tests assert the *guard* refused, not merely that the request
failed: several of these shapes (read_parquet on a text file, a bare
'/etc/passwd') error inside DuckDB anyway, so a status-only assertion would keep
passing with the guard deleted. That is the trap the original four-case test fell
into -- it tested negatives shaped like the regex that had been written.
2026-07-25 01:35:49 -07:00
..
test_api.py Frontend QA batch: date/TZ, https origin, date-422, VAPID rotation, trace-precip, partial-day gate (#70) 2026-07-24 23:13:36 +00:00
test_gzip.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
test_heartbeat.py web/worker: add a process-level liveness heartbeat (#80) 2026-07-25 04:13:47 +00:00
test_homepage.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00
test_lake_app.py lake: gate /query and stop it reading outside the two lake views 2026-07-25 01:35:49 -07:00
test_request_logging.py Stop logging /healthz + internal SSR hop, truncate logged IPs (#35) 2026-07-24 19:28:47 +00:00
test_role.py Subtree-merge thermograph-backend (origin/main) into backend/ 2026-07-22 22:01:11 -07:00