Skip to content

fix: harden admin auth, upstream ingest, estimates swap and geocoding - #197

Merged
bk86a merged 2 commits into
mainfrom
fix/security-audit-findings
Oct 28, 2026
Merged

bk86a merged 2 commits into
mainfrom
fix/security-audit-findings

Conversation

@bk86a

@bk86a bk86a commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Fixes from a security review of the service. There are no critical, high or medium issues; this addresses four confirmed low-severity defects, plus the leads that can be fixed in source.

⚠️ Breaking: admin token

/admin/* now requires a token from the new PC2NUTS_ADMIN_TOKENS (at least 32 characters each). Rate-limit bypass tokens, from the env var or the token DB, get 403 there. Set PC2NUTS_ADMIN_TOKENS on the production deployment before rolling out if anything calls /admin/refresh-estimates or /admin/memory.

Fixes

  • Admin scope: bypass tokens are handed to consumers and no longer unlock operator endpoints. A non-ASCII bearer token gets 401 instead of 500.
  • Malformed upstream ZIP: an over-long CSV field, an encrypted member or an unsupported compression method no longer stops startup. The file is skipped and its cached copy deleted. Any unexpected loader error still falls back to the stale cache, and the NSPL and NUTS-names paths get the same handling.
  • Partial rebuild:
    • If some countries fail during a refresh, they are no longer dropped from the cache for 30 days. Their rows are kept from the previous snapshot and the data is reported as stale.
    • The download is retried after PC2NUTS_DB_PARTIAL_RETRY_HOURS (default 24). It is not retried on every restart, so a file that is permanently broken upstream does not slow every boot.
    • Each worker writes the cache through its own temporary file.
  • Estimates refresh race: the new table is built first and published with one reference rebind. Requests running during the swap could previously get a 500, a false 404 or a lower-tier answer.
  • NUTS polygons: the download must be a ZIP and is written atomically. The GeoJSON member is refused above 256 MiB uncompressed (the real one is about 28 MB), and a corrupt cached copy is replaced.
  • /resolve geocoding: one time budget covers all fallback queries (PC2NUTS_PHOTON_TOTAL_TIMEOUT_SECONDS, default 10). Each worker runs at most PC2NUTS_PHOTON_MAX_CONCURRENCY geocodes at once (default 16); past that, /resolve answers postal-only instead of tying up the threadpool /lookup needs.
  • CI: packages: write is granted only to publish, and the gate jobs check out with persist-credentials: false.

Not in this PR

  • CI tool installs (ruff, bandit, pip-audit) are still unpinned. With the narrower permissions, a compromised release can no longer push an image.
  • The forwarded-IP trust used by the rate limiter is a deployment setting (PC2NUTS_FORWARDED_ALLOW_IPS) and needs no code change.

Tests

570 pass locally on Python 3.12. ruff, ruff format and bandit are clean. Each new regression test fails on main and passes on this branch:

  • tests/test_loader_resilience.py
  • the threaded estimates-swap test
  • the polygon tests
  • the Photon deadline and concurrency-cap tests
  • the admin-scope tests

- /admin/* requires an operator token from PC2NUTS_ADMIN_TOKENS; rate-limit
  bypass tokens get 403. Non-ASCII bearer tokens get 401, not 500.
- Malformed upstream ZIPs (over-long CSV field, encrypted/unsupported member)
  are skipped and uncached instead of aborting startup; unexpected loader
  errors still reach the stale-cache fallback.
- An incomplete rebuild keeps the previous snapshot's missing countries, is
  served as stale and retried after PC2NUTS_DB_PARTIAL_RETRY_HOURS instead of
  replacing the cache for the full TTL. Per-writer temp file for the DB save.
- Runtime estimates refresh publishes the new table with a single rebind, so
  concurrent /lookup and /resolve readers never see it mid-mutation.
- NUTS polygon archive: ZIP-validated, written atomically, uncompressed size
  capped at 256 MiB, corrupt cache replaced.
- Photon geocoding: total per-call budget and a per-worker concurrency cap.
- CI: packages:write only on the publish job; no persisted checkout token in
  the gate jobs.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T20:13:10.262065Z cd2de11 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd2de11970

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/data_loader.py
Comment on lines +547 to +552
except _UPSTREAM_PARSE_ERRORS as exc:
# Drop the cached copy so a restart re-downloads instead of failing on
# the same file for the rest of the cache TTL.
logger.warning("Unusable ZIP from %s (%s); discarding cached copy", url, exc)
cached.unlink(missing_ok=True)
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Roll back parsed rows before discarding an unusable ZIP

If a CSV contains valid rows before an overlong field, _parse_csv_content() has already inserted those rows into _lookup when this handler returns zero. _carry_forward_missing_countries() then sees the country as present and does not restore its remaining rows from the previous snapshot. The rebuild can consequently overwrite a complete cache with a truncated country, potentially marked fresh for the full TTL. Parse into a temporary table or roll back the rejected file's changes before continuing.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 43b6c01, with a regression test in tests/test_loader_resilience.py (TestCodexReviewRegressions).

Comment thread app/data_loader.py
Comment on lines +1146 to +1149
for cc, pc, nuts3 in rows:
if cc not in have:
_lookup[(cc, pc)] = nuts3
missing.add(cc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude intentionally disabled sources from carry-forward

When an operator disables NSPL by clearing PC2NUTS_NSPL_URL, _db_is_valid() correctly invalidates the old cache because its configuration hash changed, but this unconditional carry-forward restores the old UK rows after the otherwise successful rebuild. The same happens when removing an extra source that supplied an entire country. Those mappings remain available indefinitely, and every retry restores them again and marks the service stale. Distinguish failed sources from intentionally removed sources before retaining their countries.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 43b6c01, with a regression test in tests/test_loader_resilience.py (TestCodexReviewRegressions).

…onfigured sources

A file rejected half-way no longer leaves rows that hide its country from the
incomplete-rebuild carry-forward. Countries whose source was removed from the
configuration (NSPL unset, extra source dropped, country delisted) are not
resurrected from the previous snapshot.
@bk86a
bk86a merged commit b49c07e into main Oct 28, 2026
10 checks passed
@bk86a
bk86a deleted the fix/security-audit-findings branch October 28, 2026 20:29
@bk86a bk86a mentioned this pull request Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant