Commit 78669a9
Close the direct run.app door with a shared-secret origin gate (#11208)
## Summary
- **`api/origin_gate.py` requires the header the Cloudflare edge
stamps**, and refuses anything else with `403` before the request costs
anything. The API stands on Cloud Run with `ingress=all`, so it answers
on two addresses — `api.anyplot.ai` behind Cloudflare, and the raw
`*.run.app` URL in front of nothing. Every edge measure (bot challenge,
WAF, the cache that makes the `max-age=300` reads free) was one URL away
from being bypassed; `api/request_context.py` already documented callers
doing it.
- **Unset means off.** Nothing changes until `ORIGIN_SECRET` is set on
the service, which is also the rollback. Local dev and the test suite
never see the gate, so this can merge and deploy long before the
Cloudflare rule or the secret exist.
- **`/health` reports `origin_gate`** — `off` · `off-seen` · `ok` ·
`missing` · `mismatch` — for the request it was asked with, never the
value. That is what turns the rollout into a measurement instead of a
leap.
- **The apex Worker's source moves into `infra/cloudflare/`**, because a
Worker subrequest to a host in the same zone bypasses that zone's
Transform Rules. It now stamps the header itself, deleting any inbound
one first.
- Transferred from the sibling repo kurrentschrift (PRs #493, #495),
where this shipped and was measured live.
- **Known residual, deliberately out of scope:** 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 — so the prerendered render stays reachable via the
*app*'s raw `run.app` URL. That is a second door on a second service
(the request the API sees really did pass the edge), and closing it
means gating `anyplot-app` or refusing to proxy for `run.app` hosts,
which `bot-serving-check.yml` probes nightly. Named in
`api/origin_gate.py` and `docs/reference/api.md`; own PR.
## What is exempt, and why each one has to be
Exact paths, no prefixes:
| Path | Why |
|---|---|
| `/health` | the deploy's pre-traffic smoke probes the candidate
revision on its `run.app` tag URL, which by definition never passes the
edge — gating it makes every deploy fail closed |
| `/debug/cache/invalidate` | **anyplot-specific.** `sync-postgres.yml`
posts here from a GitHub runner over the direct `*.run.app` URL *on
purpose* — Cloudflare's bot challenge answers an unauthenticated curl
POST against `api.anyplot.ai` with a 403 HTML page. The endpoint carries
its own shared secret (`CACHE_INVALIDATE_TOKEN`, constant-time compared,
503 when unconfigured), so it is gated, just by a different lock |
| `OPTIONS` | a browser cannot attach a custom header to a preflight |
**`/seo-proxy/…` is deliberately NOT exempt**, though the sibling repo
exempts it belt-and-braces. Copilot was right that it would be a real
hole: those handlers query `SpecRepository`/`ImplRepository` on a cache
miss or an unknown id, and any request with a recognized crawler user
agent schedules an outbound Plausible event — so an exemption would
leave the API's most expensive reads open on the direct URL, which is
the cost this gate exists to refuse. The site's nginx already fetches
those pages over `https://api.anyplot.ai`, so the path carries the
header; step (b) of the rollout validates it end to end with a crawler
user agent before anything is armed, and `bot-serving-check.yml` runs
daily, so being wrong here is loud rather than silent.
**The direct paths I checked** (this is the part that does not transfer,
and had to be re-derived for this repo):
- `api.anyplot.ai` — SPA (`VITE_API_URL`), MCP clients, OG cards
embedded cross-origin, and the site's nginx for `@seo_proxy`,
`@seo_proxy_python`, `/llms-full.txt`, `/sitemap.xml`. All through the
edge → the Transform Rule stamps them.
- `anyplot.ai/api/*` — the Worker. Same-zone subrequest, so it must
stamp for itself. Its `/api/event` Plausible passthrough is preserved
untouched.
- `anyplot-api-…run.app` — the Cloud Build smoke (now sends the header)
and `sync-postgres.yml` (exempt path, above).
- `anyplot-app-…run.app` — `bot-serving-check.yml` hits the *app*
origin, whose nginx then goes out through `api.anyplot.ai`. Unaffected.
## Every header secret goes through one byte-wise comparator
`secrets.compare_digest` raises `TypeError` when either `str` holds a
non-ASCII character, and a header value reaches the application
latin-1-decoded straight from the wire. Comparing strings handed any
unauthenticated caller a one-byte way to turn a cheap 401 or 403 into an
unhandled, logged 500 (Copilot).
That was true of the gate — and of `X-Admin-Token` and `X-Cache-Token`,
which matters more: `/debug/cache/invalidate` is *exempt from the gate
on the grounds that it has its own lock*, and it is the one endpoint
reachable on the direct `run.app` URL. So the fix is one comparator in
`api/secret_compare.py`, used by all three call sites, rather than three
separate patches: a comparator that is correct in two places out of
three is exactly what nobody notices. It also refuses when either side
is missing, so an unconfigured secret can never be satisfied by an
absent header.
Pinned by tests including one that asserts the `str` comparison this
replaced *does* raise on the same input, so the others cannot quietly
stop measuring anything.
## Two changes beyond the gate itself
**The deploy step configures the revision additively —
`--update-secrets` and `--update-env-vars`.** Both `--set-` forms
replace their whole set, so anything attached to the service out of band
is stripped from every revision the pipeline creates. `ORIGIN_SECRET` is
exactly that kind of binding — attached by hand to arm, removed by hand
to roll back — and a secret-backed variable lives in the same revision
environment as a literal one, so *either* flag was a way to silently
disarm the gate on the next deploy (the second half found by Copilot,
after the first fix). It cannot simply be listed in the flags instead:
Cloud Run refuses a deploy naming a secret that does not exist, which
would break every build until step (c) below. Two flags in the deploy
step; everything else in `api/cloudbuild.yaml` is confined to the smoke
step.
**The analytics middleware moves inside `CORSMiddleware`.** The gate has
to be inside CORS (so its 403 carries the headers a browser needs to
read it as a 403 rather than as an opaque network error) *and* outside
the bot counter (so a refused request can never fire an outbound
Plausible event — `track_asset_fetch` fires per request for anything
with a crawler user agent, so a caller on the direct URL could otherwise
turn each of its own refusals into one, unthrottled, at a third-party
endpoint). In this repo the counter sat outside CORS, which makes those
two mutually exclusive; moving it in resolves it. The cache-header
middleware stays outside CORS, where its `setdefault` for the `/og/`
cards depends on being. `api/main.py` now carries the stack order and
the reason for each position.
The only behavioural consequence of the move: a CORS preflight no longer
reaches the counter. Preflights carry the browser's user agent, and both
tracking functions return early unless `detect_ai_agent` classifies the
UA, so nothing that was being counted stops being counted.
## Rollout — in this order, and measured at each step
**(a) Merge and deploy with the check off.** Nothing to configure;
`ORIGIN_SECRET` does not exist yet, `gate_is_armed()` is false, every
path behaves exactly as today. Confirm with:
```bash
curl -s https://api.anyplot.ai/health # expect "origin_gate":"off"
```
**(b) Put the Transform Rule live and give the Worker its binding — then
measure `off-seen` on EVERY path.** Cloudflare dashboard: Rules →
Transform Rules → Modify Request Header → *set static* `X-Origin-Secret`
for `http.host eq "api.anyplot.ai"`. Then deploy
`infra/cloudflare/anyplot-api-proxy.js` with the `ORIGIN_SECRET` secret
binding (procedure in `infra/cloudflare/README.md`). The gate is still
off, so nothing can break; what this step buys is the evidence:
```bash
curl -s https://api.anyplot.ai/health # must read "off-seen"
curl -s https://anyplot.ai/api/health # must read "off-seen" ← the Worker; this is the one that reads "off" if the binding is missing
curl -s https://<api-run-url>/health # must stay "off" — that is the door being closed
# nginx rides on the first line's verdict; probe it end to end:
curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1)' https://anyplot.ai/scatter-basic | head -5
curl -sI https://anyplot.ai/llms-full.txt
```
**Do not proceed while any path that must keep working still reads
`off`.**
**(c) Create the secret and arm the service.** Both the Cloud Run
runtime and the Cloud Build trigger use the same identity,
`239660669828-compute@developer.gserviceaccount.com`, so one grant
covers the service *and* the smoke step's read.
```bash
# Never `echo` — it appends a newline. (The config strips whitespace as a
# second net, but the value should be right in the first place.)
gcloud secrets create ORIGIN_SECRET --project=anyplot --replication-policy=automatic
printf %s "<value>" | gcloud secrets versions add ORIGIN_SECRET --project=anyplot --data-file=-
gcloud secrets add-iam-policy-binding ORIGIN_SECRET --project=anyplot \
--member="serviceAccount:239660669828-compute@developer.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
# Same value into the Cloudflare Transform Rule and the Worker binding.
# Same value into the Cloudflare Transform Rule and the Worker binding.
```
Then arm the service. **The full block is in `docs/reference/api.md` §
Origin gate** — it lives in the repository rather than in this
description, because a procedure that exists only in a PR body is one
nobody finds at 2 a.m. Three things in it are not obvious, each from a
Copilot round:
1. **Refuse to act while a candidate revision is in flight.** `services
update` clones the service's *latest* template, not the serving one, and
the pipeline deliberately leaves each build's smoked-but-unpromoted
candidate as latest — so arming during a deploy would ship that build's
image along with the gate, and naming the new revision precisely does
not change which image it inherits. The block asserts
`latestReadyRevisionName == the revision serving 100%` and stops
otherwise.
2. **Pin the secret to a version number, never `:latest`.** Cloud Run
resolves a secret-backed variable when each instance starts, so with
`:latest` a new secret version reaches new instances while older ones
keep the old value — and since the edge stamps exactly one value, that
shows up as intermittent 403s *inside a single revision*.
3. **Promote by name, never `--to-latest`** — the same hazard as (1),
and the reason `api/cloudbuild.yaml` refuses that flag.
Rotation gets its own paragraph there: the gate accepts exactly one
value, so there is no overlap window. Roll back, rotate both sides, arm
again on the new version number.
**(d) Verify.**
```bash
curl -s https://api.anyplot.ai/health # "ok"
curl -s https://anyplot.ai/api/health # "ok"
curl -s https://<api-run-url>/health # "missing" ← the door is now shut
curl -s -o /dev/null -w '%{http_code}\n' https://<api-run-url>/libraries # 403
curl -s -o /dev/null -w '%{http_code}\n' https://api.anyplot.ai/libraries # 200
```
Then walk the site once (gallery, a spec page, the stats page), fetch an
OG card cross-origin, and let one `sync-postgres` run finish — its cache
flush must still return 200.
**(e) Rollback** — one variable, no code change:
The same block as arming, with `--remove-secrets=ORIGIN_SECRET` in place
of `--update-secrets` and a `disarm-` suffix — including the
in-flight-candidate guard, which matters more here than when arming.
Removing the Worker binding is **not** a rollback: while the service is
armed, that takes `anyplot.ai/api/*` down instead of freeing it. Roll
back on the API side, always.
## Follow-up this PR deliberately leaves open
`/debug/cache/invalidate` is exempt because `sync-postgres.yml` has no
front door. The cleaner end state is for that workflow to send
`X-Origin-Secret` from a repository secret, at which point the exemption
can go. That needs a GitHub Actions secret plus a change to
`.github/workflows/sync-postgres.yml`, which is out of this PR's scope —
noted here so it is not lost.
## Test plan
- [x] `tests/unit/api/test_origin_gate.py` — 57 tests: dormant by
default (including with a wrong header), the armed gate across five
header shapes and six methods, the exemption list both as live requests
and as assertions on the list itself (including that `/seo-proxy/…` is
refused), preflight and CORS-headers-on-the-403, the analytics
middleware never firing on a refusal — for an asset path *and* a crawler
page — all five `/health` verdicts, the non-ASCII header on all three
secrets, and the trailing-newline strip on `ORIGIN_SECRET` *and* the
other Secret-Manager-backed values.
- [x] `uv run pytest tests/unit` — 1818 passed, 1 skipped (pre-existing
local skip: MonoLisa italic not cached).
- [x] `uv run ruff check .` / `ruff format --check .` — clean.
- [x] `uv run --extra typecheck mypy api core` — no issues in 37 source
files.
- [x] `api/cloudbuild.yaml` parses; step ids unchanged (`build-image`,
`push-image`, `push-latest`, `deploy`, `smoke`, `promote`, `get-url`).
- Not verifiable before merge: the Cloud Build smoke's new lines and the
Cloudflare side. The smoke is written so that a missing secret or a
missing permission yields an empty value and bare probes — correct while
the gate is off, loud at `/libraries` once it is on — and it accepts
`off`/`off-seen`, so it cannot take the deploy pipeline down during the
rollout or after a rollback.
## Checklist
- [x] `CHANGELOG.md` updated under `[Unreleased]` — one `### Added`
entry for the gate, two `### Changed` entries for the deploy flag and
the middleware order.
- [x] Docs updated: `docs/reference/api.md` (new "Origin gate" section,
`/health` response), `docs/development.md` (env table),
`docs/reference/repository.md` and `agentic/docs/project-guide.md` (both
repository maps get `infra/`), `.env.example`, and
`infra/cloudflare/README.md` for the Worker and the measuring procedure.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>1 parent 9c85763 commit 78669a9
16 files changed
Lines changed: 1233 additions & 33 deletions
File tree
- agentic/docs
- api
- routers
- core
- docs
- reference
- infra/cloudflare
- tests/unit/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
60 | 67 | | |
61 | 68 | | |
62 | 69 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
31 | 66 | | |
32 | 67 | | |
33 | 68 | | |
| |||
73 | 108 | | |
74 | 109 | | |
75 | 110 | | |
76 | | - | |
77 | 111 | | |
78 | 112 | | |
79 | 113 | | |
| |||
256 | 290 | | |
257 | 291 | | |
258 | 292 | | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
259 | 312 | | |
260 | 313 | | |
261 | 314 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
202 | 202 | | |
203 | 203 | | |
204 | 204 | | |
| 205 | + | |
| 206 | + | |
205 | 207 | | |
206 | 208 | | |
207 | 209 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | | - | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
72 | 84 | | |
73 | 85 | | |
74 | | - | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
75 | 96 | | |
76 | 97 | | |
77 | 98 | | |
| |||
112 | 133 | | |
113 | 134 | | |
114 | 135 | | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
115 | 151 | | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
116 | 168 | | |
117 | 169 | | |
118 | 170 | | |
119 | 171 | | |
120 | | - | |
121 | | - | |
122 | | - | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
123 | 175 | | |
124 | 176 | | |
125 | | - | |
126 | | - | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
127 | 181 | | |
128 | 182 | | |
129 | 183 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
161 | 162 | | |
162 | 163 | | |
163 | 164 | | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
164 | 188 | | |
165 | 189 | | |
166 | 190 | | |
167 | 191 | | |
168 | 192 | | |
169 | 193 | | |
170 | | - | |
171 | | - | |
172 | | - | |
173 | | - | |
174 | | - | |
175 | | - | |
176 | | - | |
177 | | - | |
178 | | - | |
179 | | - | |
180 | | - | |
181 | | - | |
182 | | - | |
183 | | - | |
184 | 194 | | |
185 | 195 | | |
186 | 196 | | |
| |||
214 | 224 | | |
215 | 225 | | |
216 | 226 | | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
217 | 247 | | |
218 | 248 | | |
219 | 249 | | |
| |||
0 commit comments