feat(security): an origin gate for the app container, shipped off - #11221
Merged
Conversation
The API's gate (#11208) closed one of two doors and said so in its own docstring: `anyplot-app` stands with `ingress=all` beside it, serving the whole site from a `*.run.app` URL with no bot challenge, no WAF and no rate limit, and relaying any crawler user agent through `@seo_proxy` into a repository query and an outbound Plausible event. `app/origin-gate.conf.template` is the nginx half of the same mechanism: the base image's own envsubst entrypoint renders the maps with the shared secret before nginx starts, and every server block refuses what the Cloudflare edge did not stamp. `ORIGIN_GATE` unset means off, `on` means 403, and armed with no secret fails CLOSED — the map keys are tagged so an empty value cannot become "match anything". Nothing is armed by merging. `/_health` reports `X-Origin-Gate` with the API's five verdicts, so every route into the container can be measured while the gate is still off; the rollout, the hostnames the Transform Rule has to cover and the rollback are in infra/cloudflare/README.md. The three callers that reach this origin without the edge now carry the header themselves: the pre-traffic smoke, the daily bot monitor, and the apex Worker's `/api/event` branch — the one path that Worker sends to this container, and one a Worker subrequest leaves unstamped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
A critical secret-disclosure issue and multiple rollout-safety issues remain unresolved.
Pull request overview
Adds a disabled-by-default shared-secret origin gate protecting the app’s direct Cloud Run endpoints.
Changes:
- Adds nginx gate enforcement and health verdicts.
- Authenticates legitimate direct callers.
- Adds container tests, monitoring, documentation, and rollout guidance.
File summaries
| File | Change | Review |
|---|---|---|
tests/unit/api/test_app_origin_gate.py |
Adds configuration guards. | Nit (line 28, 1 vote): Correct the inconsistent file counts. |
infra/cloudflare/README.md |
Documents configuration and rollout. | Nit (line 299, 1 vote): Convert commented rollout actions into numbered Markdown steps. Moderate (lines 327, 340, 1 vote): Explicitly promote the newly armed revision. Moderate (line 328, 1 vote): Pin a numeric secret version instead of latest. |
infra/cloudflare/anyplot-api-proxy.js |
Stamps Worker analytics requests. | No unresolved finding. |
docs/reference/seo.md |
Documents protected crawler routing. | No unresolved finding. |
docs/reference/api.md |
Updates origin-gate documentation. | No unresolved finding. |
changelog.d/app-origin-gate.md |
Records the security feature. | No unresolved finding. |
app/origin-gate.conf.template |
Defines gate verdict and denial maps. | Moderate (lines 85, 97, 1 vote): Match on case-sensitively to prevent accidental activation. |
app/nginx.conf |
Enforces the gate and exposes health status. | Critical (lines 168, 470, 1 vote): Clear X-Origin-Secret before proxying requests to Plausible and add an egress guard. |
app/Dockerfile |
Renders the gate template at startup. | No unresolved finding. |
app/cloudbuild.yaml |
Authenticates candidate-revision probes. | No unresolved finding. |
.github/workflows/ci-image.yml |
Tests the built container’s gate states. | No unresolved finding. |
.github/workflows/bot-serving-check.yml |
Authenticates direct crawler probes. | No unresolved finding. |
Review details
Suppressed comments (5)
app/nginx.conf:472
- This second server block has the same secret-egress problem: accepted edge requests retain
X-Origin-Secret, and both Plausible proxy locations forward request headers by default. Clear the header before proxying toplausible.io; otherwise enabling this hostname later exposes the shared gate secret.
if ($origin_gate_deny) {
return 421;
}
app/origin-gate.conf.template:97
- This is not an exact case-sensitive exemption: nginx
mapliteral keys ignore case. With the gate armed, an unauthenticated request such as/_HEALTHis therefore admitted; because the nginx location is case-sensitive, it falls through to the normal SPA/bot routing rather than the health handler, bypassing the origin gate. Make this a case-sensitive anchored regex and update the static/runtime tests to cover a case variant.
"/_health" 1;
infra/cloudflare/README.md:304
- This rollout is a user procedure, but its actions are embedded as lettered comments inside a shell block. The repository documentation contract requires numbered Markdown steps with one action per step and says manual actions must not be hidden in code-block comments; move steps (a)–(f), especially the dashboard and Worker deployment actions, into a numbered list with command blocks under the relevant steps.
### Rollout
Ordered so that nothing is armed before it has been measured. Steps (a) and (b)
are safe on their own and can sit for days.
```bash
infra/cloudflare/README.md:341
- This primary rollback also creates an unserved revision because traffic is pinned to the currently named revision. During an incident, removing
ORIGIN_GATEwithout an explicit revision suffix andupdate-trafficleaves the armed revision at 100%, so the rollback does not take effect. Use the same serving-image and explicit-promotion sequence as the API rollback.
gcloud run services update anyplot-app --region=europe-west4 --project=anyplot \
--remove-env-vars=ORIGIN_GATE
tests/unit/api/test_app_origin_gate.py:28
- The introduction counts six files (item 4 names two), so this sentence's “five” and “four” are inconsistent with the test module's own description.
None of the five can see the other four, and every one of them fails silently:
- Files reviewed: 12/12 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… an exception Two findings from the first CI run of the new container smoke, both of which only a running container could produce. nginx cannot hash a `map` key longer than one bucket, and the default bucket is the processor's cache line. The tagged key is `presented:` plus the whole secret, so a 32-byte secret in hex is 74 characters and nginx refuses to start with "could not build map_hash" — with the gate OFF it starts perfectly, because the key is short then, so the failure would have appeared at the exact moment of arming and nowhere earlier. The template now sets map_hash_bucket_size 512, the smoke uses a production-length secret so the ceiling stays exercised, and a test pins the directive. Hadolint DL3064 reads the ENV variable NAME and warns that a secret may be baked into the image. The value is the empty string, and it is declared for the opposite reason: so a service supplying no secret renders a config that refuses everyone rather than one nginx cannot parse. Renaming would silence the rule and break a four-place contract, so the exception sits on an ENV instruction of its own, with its reason beside it and the workflow's claim updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Four review findings, three of them defects. The gate header was forwarded to every upstream. nginx passes incoming request headers to a proxied server by default, so once the edge stamps X-Origin-Secret on this host, the two Plausible locations in each server block would have handed the shared secret to a third party — and with it the API service's key, since both take the same value. Every proxy_pass in the file now clears it, which is one rule rather than a list of the dangerous ones, and a test refuses a location that proxies without it. Arming was written as two flags. This service pins traffic to a named revision, so `gcloud run services update` alone creates a revision that serves nothing while /_health still answers off. The runbook is now the API's own block: skip if a build is in flight, pin the SERVING image rather than the latest template, pin the secret to a NUMBER (with :latest a new version reaches new instances only, which reads as intermittent 403s inside one revision), then promote by name. Rolling back is its own block that looks nothing up, because the secret may be disabled during the incident it is undoing. A plain map key is matched without regard to case, so ORIGIN_GATE=ON arms as well. Kept, and documented instead of narrowed: nobody sets it to ON without meaning to arm, and the failure the other way — an operator who armed the gate, was told nothing and still has an open origin — is the one worth avoiding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
3 tasks
MarkusNeusinger
added a commit
that referenced
this pull request
Sep 4, 2026
…ut (#11222) ## Summary - Fix the scripted Worker deploy recipe in `infra/cloudflare/README.md`: the multipart `curl` example uploaded the module under curl's default filename (the local basename `anyplot-api-proxy.js`), which Cloudflare's `main_module: "worker.js"` never matches, so a scripted deploy answered `400 — Uncaught Error: No such module: worker.js` (observed live 2026-09-04). Add the explicit `filename=worker.js` override plus a sentence explaining why Cloudflare needs it. - Replace the stale "After this lands, the Worker needs a redeploy" callout with a "Deployed state (2026-09-04)" note: the Worker was redeployed from the current `.js` after #11221, the Transform Rule now covers `anyplot.ai`, `www.anyplot.ai` and `api.anyplot.ai`, and `/api/event` measured `off-seen`. The standing rule (the `.js` mirrors the deployed bytes) is kept. ## Plan N/A — docs-only fix, no spec/plan file. ## Test plan - [x] Read the full corrected recipe and callout in `infra/cloudflare/README.md` to confirm the `filename=worker.js` attribute and the new deployed-state note read correctly in context. - [x] `uv run python -m tools.changelog check --base origin/main` passes with the new fragment. - [ ] No live redeploy performed by this PR (docs-only); a future scripted deploy is the real-world verification of the fixed recipe. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
api/origin_gate.pyclosed the API service's door and wrote the app service's door down as the thing it could not close from its own side:anyplot-appstands withingress=all, serves the whole site fromhttps://anyplot-app-r3tvmejsmq-ez.a.run.appwith no bot challenge, no WAF and no rate limit, and relays any crawler user agent through@seo_proxytohttps://api.anyplot.ai— where the edge stamps the API's secret legitimately, so the API gate cannot tell.app/origin-gate.conf.templateis the nginx half of the same mechanism: same secret, same five verdicts,ORIGIN_GATEunset = off.ORIGIN_GATE=off, the service declares no environment variables at all, and/_healthalready reportsX-Origin-Gate— so every route into the container can be measured before anything is switched on. The runbook is ininfra/cloudflare/README.md§ "The site's own origin", summarised at the bottom here./api/event.The mechanism, in three sentences
app/origin-gate.conf.templaterenders into/etc/nginx/conf.d/00-origin-gate.confat container start, via the entrypoint script20-envsubst-on-templates.shthatnginxinc/nginx-unprivileged:alpinealready ships — no start script of ours, andNGINX_ENVSUBST_FILTER=^ORIGIN_keeps envsubst away from$hostand$uri. Sevenmapblocks turn the presented header into$origin_gate_status(off·off-seen·ok·missing·mismatch, the API's own vocabulary) and$origin_gate_deny; every server block inapp/nginx.confcarriesif ($origin_gate_deny) { return 421; }at server level, which runs in the server-rewrite phase and therefore covers every location the block has and every location added later. The secret is written in exactly one map key,app/nginx.confnever names$http_x_origin_secretat all, and everyproxy_passclears the header so no upstream is ever handed it — three rules with a test each, plus a CI smoke that greps the refusal page and the container log for the value.The finding the brief asked for:
Hostwould have worked, and still cannot be the mechanismapi/origin_gate.pyguessed that$hostat this origin might be therun.appname for all traffic, which would have settled the question. It is not:gcloud beta run domain-mappings list --region=europe-west4 --project=anyplotanyplot.ai → anyplot-app,www.anyplot.ai → anyplot-app,api.anyplot.ai → anyplot-apiThey are Cloud Run domain mappings, so Cloudflare forwards the original
Hostand$hostreally does distinguish the edge from the raw URL — and the value cannot be spoofed, because Google's frontend answers a foreign Host on arun.appaddress with its own 404 before the container is reached (measured in #11208).A Host rule still cannot be the gate.
bot-serving-check.ymlprobes this exact origin with crawler user agents because Cloudflare 403s GitHub-runner IPs even for a UA-spoofed Googlebot; it cannot spoof the Host either, and any exception keyed on something it could present instead — a header it invents, a user agent — is public with this repository. The exception has to be the shared secret. Once the workflow carries the secret, the Host rule buys nothing the header does not, so it is not built.Every hostname this container serves
The Transform Rule has to cover all of them, or arming locks out the visitors it protects.
anyplot.aiwww.anyplot.aicurl -sI https://www.anyplot.ai/→ 200, noLocation)anyplot.ai/api/eventfetch(request)back to this originpython.anyplot.aiserver_nameinapp/nginx.confwith no DNS record and no domain mapping (curl→Could not resolve host, checked 2026-09-04)anyplot-app-r3tvmejsmq-ez.a.run.appanyplot-app-239660669828.europe-west4.run.appcandidate---anyplot-app-r3tvmejsmq-ez.a.run.appThe rule must be a Set, not an Add: a caller supplying its own
X-Origin-Secrethas to have it replaced.Every legitimate direct caller, and what each now sends
app/cloudbuild.yamlpre-traffic smokeX-Origin-Secreton every probe, read from Secret Manager inside the stepavailableSecrets, which resolves at build start and would fail every build until the secret exists — the same reasoningapi/cloudbuild.yamlalready carries. It also asks/_healthfor the verdict before any content probe, so a wired-up-wrong secret is reported as itself instead of as a mystifying 403 on the home page..github/workflows/bot-serving-check.ymlORIGIN_SECRETrepository secret, on all ~36 probes/_healthfirst:missingandmismatchare hard failures with a message naming the secret;offis a warning; an absent header is a warning naming deploy lag. Without that, an armed gate plus a missing secret would open an incident saying "every crawler page is broken"./api/eventORIGIN_SECRETbindinganyplot.ai/api/eventis the only path under the Worker's route that goes to the site's origin instead of the API host, and a Worker subrequest to a host in the same zone skips that zone's Transform Rules — the exact findinginfra/cloudflare/README.mdexists for, biting a second time. Arming without this answers every Plausible pageview on the site with a 403, quietly.gcloud run services describe→startupProbe: tcpSocket: port 8080,failureThreshold 1. Not an HTTP probe, so no exemption.indexnow-submit.yml) + Bing's key verificationhttps://anyplot.ai/<key>.txt, i.e. through the edge.https://anyplot.ai/..., through the edge.Also checked and empty:
gcloud monitoring uptime list-configs --project=anyplot→Listed 0 items.No Lighthouse CI workflow exists. TheORIGIN_SECRETrepository secret already exists (created 2026-09-03 for the API rollout, used bysync-postgres.yml), and the build service account239660669828-compute@developer.gserviceaccount.com— the account both triggers run as — already holdsroles/secretmanager.secretAccessoron it. So no grant and no new secret are needed; the runbook only confirms them.Correcting a claim from #11220
That PR's description recorded, as the reason a Cloud Run env-var switch was not built:
That reading came from the wrong file in the image's repository.
nginxinc/nginx-unprivileged:alpineisFROM nginxinc/nginx-unprivileged:1.31.5-alpine-slim, andmainline/alpine/Dockerfile— the variant that adds the modules — genuinely contains no such lines, because they are in the base it inherits from.mainline/alpine-slim/Dockerfile:# nginx user must own the cache and etc directory to write cache and tweak the nginx config && chown -R $UID:0 /var/cache/nginx \ && chmod -R g+w /var/cache/nginx \ && chown -R $UID:0 /etc/nginx \ && chmod -R g+w /etc/nginx/etc/nginx/conf.dis owned by uid 101, which is the uid the container runs as. Now confirmed live rather than read: the new CI job's log carries20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/00-origin-gate.conf.template to /etc/nginx/conf.d/00-origin-gate.conf. #11220 was right that nothing could exercise it before it mattered — which is what that job is for, and it earned its keep on the first run (below).Verification
CI builds the app image and runs the gate against it.
app/Dockerfilewas hadolinted but never built before Cloud Build, i.e. after the merge — and what it produces is not a program that fails to import but an nginx whose config is rendered at container start. The newapp-imagejob inci-image.ymlruns the real image three ways:/200;/_health→X-Origin-Gate: off; with any header →off-seen/403 bare, 403 with a wrong secret, 200 with the right one;/_healthstill 200 (exempt) and reportingmissing/mismatch/okdocker logs;nginx -tvalidates the rendered config (nginx -Twould print the secret into the CI log, so it is never run)It failed on its first run, with a defect nothing else here could have found. With the gate armed:
nginx cannot hash a
mapkey longer than one bucket, and the default bucket is the processor's cache line — 64 bytes. The tagged key ispresented:plus the whole secret, so a 32-byte secret written as hex is 74 characters. With the gate off the container starts perfectly, because the key is short then; the failure would have appeared at the exact moment of arming and nowhere earlier. Cloud Run would have kept the previous revision serving, so it would have been a safe failure rather than an outage — but it would have been a failure in the middle of the one procedure this whole PR exists to make undramatic. The template now setsmap_hash_bucket_size 512, the smoke uses a production-length 64-character secret so the ceiling stays exercised, and a test pins the directive.The rendered config, parsed by nginx's own grammar. No Docker in this environment, so
crossplane(nginx's own config parser) was run over the template rendered three ways and assembled into the samehttp {}context the container has:Worth noting for calibration: crossplane parses, it does not build hash tables, so it passed the
map_hashdefect cleanly. A text check cannot replace a running container, which is the argument for the CI job.Static guards,
tests/unit/api/test_app_origin_gate.py(14 cases), next door to the API gate's own tests: every server block gated and each with its ownerror_page/@origin_denied; the gate before the trailing-slash rewrite;app/nginx.confnever naming$http_x_origin_secret; every$origin_gate_*it reads defined by the template; the secret written once and tagged; the hash bucket raised; noproxy_passwithout clearing the header; the exemption exactly/_health; both/_healthblocks reporting the verdict and re-including the header snippet; the Worker stamping and deleting-before-stamping on/api/event; the smoke and the monitor carrying the header; the Dockerfile's three lines.Local gates:
uv run pytest tests/unit tests/integration— 1988 passed.ruff check .,ruff format --check .,mypy api coreclean.uv run python -m tools.changelog check --base origin/main— 7 fragments well-formed.No
/verify-frontendrun, and the reason is worth stating rather than skipping: the changed behaviour is nginx's, there is no SPA change in the diff (no file underapp/src, no TypeScript at all), and the flow cannot be driven from a browser without the container. The container smoke above is that loop, and the deploy's pre-traffic smoke is the second one.Review round
Four findings, three of them defects, all applied; threads answered and resolved.
/js/script.jsand/api/eventwould have handed the shared secret to plausible.io — and with it the API service's key, since both take the same value. Fixed wider than reported: the rule is now that the header is consumed by this server and never forwarded, so everyproxy_passin both blocks clears it, and a test refuses a location that proxies without doing so.app/cloudbuild.yamlpromotes with--to-revisions=<name>=100), sogcloud run services updatealone creates an armed revision that serves nothing — and step (e) would have readoffon a path that carries the header. The runbook is now the API's own block, copied rather than paraphrased: refuse while a Cloud Build is in flight, pin the serving image rather than the latest template,--revision-suffix, thenupdate-traffic --to-revisions=…=100. Rolling back is its own block that looks nothing up.ORIGIN_SECRET:latestinstead of a pinned version. Cloud Run resolves a secret-backed variable when each instance starts, so a rotation reaches new instances while older ones keep the old value — intermittent 403s inside one revision. The block resolves the newest ENABLED version number and refuses if there is none.ORIGIN_GATE=ONalso arms, because a plainmapkey is matched without regard to case. Kept, and the documentation corrected instead: nobody sets that variable toONwithout meaning to arm, and the other failure direction — an operator who armed the gate, was told nothing, and still has an open origin — is the one worth avoiding.One more, from CI rather than review: hadolint DL3064 reads the ENV variable name and warns that a secret may be baked into the image. The value is the empty string, and it is declared for the opposite reason — so a service supplying no secret renders a config that refuses everyone rather than one nginx cannot parse. Renaming would silence the rule and break a four-place contract (Secret Manager, the API service, the Worker binding, the repository secret), so the exception sits on an
ENVinstruction of its own with its reason beside it, per the repository's own hadolint convention, andci-image.yml's claim thatapp/Dockerfileneeds no exceptions is updated.Two decisions worth reviewing
return 421, notreturn 403. Anerror_page 403sends the refusal back through the server-rewrite phase, where it hits the sameifagain and loses the custom page. A distinct internal code also keeps this trick apart from the crawler's418. nginx generates a 421 of its own only for a coalesced HTTP/2 connection with a mismatched authority, which cannot happen here — Cloud Run speaks HTTP/1.1 to this container (ports: name: http1).X-Origin-Gateso a half-applied rotation saysmismatchinstead of just failing.api/secret_compare.py. nginx lowercases amapsource before hashing and offers no constant-time primitive. Both are written down at the top of the template; against a random 256-bit secret the answer to both is the entropy, not the comparison.Rollout — for the main session
Steps (a) and (b) are safe on their own and can sit for days. The arm and the rollback are full blocks, not one-liners — they live in
infra/cloudflare/README.md§ "Arming, in full" and § "Rolling back", and mirror the API's.Rotation now touches five copies of one value: Secret Manager, the API service, the app service, the Worker binding and the GitHub repository secret. Roll back, rotate, arm again; the gate is off in between, which is the documented safe state. Note also the secret's length ceiling: the map key is
presented:plus the secret and the bucket is 512 bytes, so a secret past roughly 500 characters would keep the armed revision from ever becoming ready.Plan
N/A — audit item A30, decided directly.
Test plan
app-image: the real image passes the gate matrix — off / armed / armed-with-no-secret, the exempt path, each verdict, the refusal page, and the secret in neither the page nor the logscrossplaneparses the rendered config in all three states inside the container's ownhttp {}; no undefined variablesuv run pytest tests/unit tests/integration— 1988 passed, 14 of them new intests/unit/api/test_app_origin_gate.pyuv run ruff check .+ruff format --check .+mypy api core— cleanuv run python -m tools.changelog check --base origin/main— fragment well-formed/etc/nginxownership verified againstnginxinc/docker-nginx-unprivilegedmainline/alpine-slim/Dockerfile, then confirmed live in the CI job's entrypoint loggcloud run services describe anyplot-app— no env vars today,startupProbeistcpSocket(no HTTP probe to exempt)off-seenanyplot.ai/api/eventanswering 202 rather than 403🤖 Generated with Claude Code
https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3