Skip to content

security(gate): the cache flush carries the header instead of being waved through - #11214

Merged
MarkusNeusinger merged 3 commits into
mainfrom
infra/origin-gate-rest
Sep 3, 2026
Merged

security(gate): the cache flush carries the header instead of being waved through#11214
MarkusNeusinger merged 3 commits into
mainfrom
infra/origin-gate-rest

Conversation

@MarkusNeusinger

Copy link
Copy Markdown
Owner

Before merging, one thing has to exist: a repository secret ORIGIN_SECRET, set to the same value as the ORIGIN_SECRET on the anyplot-api Cloud Run service. Without it the sync's cache-flush step fails with a message naming exactly that. The gate is already armed in production (/health reports origin_gate: "ok"), so this is not a dormant path.

Two loose ends from #11208.

1. The cache flush stops being an exemption

/debug/cache/invalidate was exempt from the origin gate because sync-postgres.yml had no front door: it posts from a GitHub runner to the direct *.run.app URL on purpose, since Cloudflare's bot challenge answers an unauthenticated curl POST against api.anyplot.ai with a 403 HTML page.

The workflow now stamps X-Origin-Secret itself. EXEMPT_PATHS is down to one entry — /health, which the deploy smoke needs on the candidate's tag URL, and which is therefore structural rather than a convenience.

Worth stating plainly why this is the better shape rather than a lateral move: an exempt path is one anybody may POST to from anywhere, with only CACHE_INVALIDATE_TOKEN standing behind it. A caller that carries the header needs no hole in the gate at all, and the two locks then sit in series. The suite pins both ends:

  • POST /debug/cache/invalidate with the gate armed and no header → 403 (the gate)
  • the same with the header → 503 (the endpoint's own fail-closed answer, with no CACHE_INVALIDATE_TOKEN configured) — reached, not pre-empted

A missing repository secret is a named failure, not a silent one: the step prints Cache invalidation was refused by the origin gate (HTTP 403). Set the ORIGIN_SECRET repository secret … and exits 1, rather than letting the cache go quietly stale. An absent secret sends no header at all rather than an empty one, so the log reads "missing secret" instead of "rotation mismatch".

docs/reference/api.md's exempt-path table and api/origin_gate.py's docstring both move with it.

2. The app origin — measured, and the design that would close it

The gate protects the API service's own door. The APP service also stands with ingress=all, and its nginx relays a crawler user agent through @seo_proxy to api.anyplot.ai, where the edge stamps the header legitimately. #11208 described this; it is now measured:

$ curl -A "…Googlebot/2.1…" https://anyplot-app-….run.app/scatter-basic
HTTP 200
<link rel="canonical" href="https://anyplot.ai/scatter-basic" />

The prerendered page, its DB queries and its Plausible event, without the caller having passed the edge.

This PR does not close it, and the reason is worth more than the attempt would have been. Two facts came out of probing it, and both are now recorded in api/origin_gate.py:

  • A Host rule would be a real boundary, not theatre. The obvious objection is that anyone could send Host: anyplot.ai to the run.app URL and walk through a host check. They cannot — Google's frontend routes by Host and answers a foreign one with its own 404 before the container is reached:

    $ curl -H "Host: anyplot.ai" https://anyplot-app-….run.app/scatter-basic
    HTTP 404   <title>Error 404 (Not Found)!!1</title>
    

    So on that origin $host is always the run.app name, and app/nginx.conf could refuse @seo_proxy for it.

  • That alone breaks bot-serving-check.yml, which probes exactly this origin with crawler UAs — deliberately, because Cloudflare 403s GitHub-runner IPs even for a UA-spoofed Googlebot — and cannot spoof the Host either. An exception keyed on a UA or a header value the workflow sends is worthless: this repository is public, so the value is public with it. The exception has to be the shared secret, which means the app's nginx must learn the secret: template the config (nginx-unprivileged ships the envsubst entrypoint), attach ORIGIN_SECRET to anyplot-app in app/cloudbuild.yaml, add a Cloudflare Transform Rule for the anyplot.ai host (today's covers api.anyplot.ai only — without it an enforcing config locks out every human visitor), and hand the workflow the same secret.

Four coordinated changes, two of them in the dashboard, one able to take the whole site down if it lands out of order — and no local nginx here to test any of it against. Owner call, filed where the next person to ask will look.

Verification

pytest tests/unit/api — 761 passed. ruff check, ruff format --check, mypy api core clean. The workflow's YAML parses and its cache-flush step's script passes bash -n after extraction. The two curl probes above were run against the live origins.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

…being waved through

/debug/cache/invalidate was exempt from the origin gate because
sync-postgres.yml has no front door: it posts from a GitHub runner to the
direct *.run.app URL on purpose, since Cloudflare's bot challenge answers an
unauthenticated curl POST against api.anyplot.ai with a 403 HTML page.

The workflow now sends X-Origin-Secret itself, out of an ORIGIN_SECRET
repository secret, so EXEMPT_PATHS holds exactly one entry — /health, which the
deploy smoke needs on the candidate's tag URL. An exempt path is one anybody
may POST to from anywhere with only CACHE_INVALIDATE_TOKEN behind it; a caller
that carries the header needs no hole at all. The suite pins both locks: 403
without the header, 503 (the endpoint's own fail-closed answer) with it. A
missing repository secret fails that step with a message naming it.

The second door the gate still does not close — a crawler UA reaching the
prerendered pages through the APP service's raw run.app URL — is now measured
rather than suspected, and origin_gate.py records the two facts that decide how
it can be closed: Cloud Run answers a foreign Host header with its own 404, so
a host rule in app/nginx.conf would be a real boundary rather than theatre; and
bot-serving-check.yml probes exactly that origin and cannot spoof the host
either, so its exception has to be the shared secret — which means templating
the app's nginx, attaching the secret to anyplot-app, and a Cloudflare Transform
Rule for the anyplot.ai host that does not exist yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Copilot AI balanced review requested due to automatic review settings September 3, 2026 21:18
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Update the stale security-boundary comments and repository-secret rotation procedure.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Moves cache invalidation behind the API origin gate by supplying the shared secret from GitHub Actions.

Changes:

  • Removes the cache endpoint exemption and adds regression tests.
  • Sends X-Origin-Secret during database synchronization.
  • Updates API documentation and the changelog.
File summaries
File Description
tests/unit/api/test_origin_gate.py Tests gated and authorized cache invalidation.
docs/reference/api.md Documents revised gate behavior; rotation steps need updating.
CHANGELOG.md Records the security change.
api/origin_gate.py Removes the cache endpoint exemption; related comments remain stale.
.github/workflows/sync-postgres.yml Adds the origin-secret header and explicit 403 handling.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread api/origin_gate.py
Comment thread docs/reference/api.md
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…s along

Copilot: core/config.py still said exactly two paths stay exempt including this
endpoint, api/routers/debug.py still said the cache flush is exempt and
therefore directly reachable, and the rotation procedure named the Secret
Manager version, the Transform Rule and the Worker binding but not the new
repository copy — after which the workflow keeps sending the old value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
@MarkusNeusinger
MarkusNeusinger merged commit 887579f into main Sep 3, 2026
14 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the infra/origin-gate-rest branch September 3, 2026 21:57
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.

2 participants