Close the direct run.app door with a shared-secret origin gate - #11208
Close the direct run.app door with a shared-secret origin gate#11208MarkusNeusinger wants to merge 12 commits into
Conversation
The API stands on Cloud Run with ingress=all, so it answers on two addresses: api.anyplot.ai, which Cloudflare proxies, and the raw *.run.app URL, which it does not. Everything the edge enforces — the bot challenge, the WAF, the cache that makes the max-age=300 reads free — was one URL away from being bypassed, and api/request_context.py already documented callers doing it. A Cloudflare Transform Rule stamps X-Origin-Secret onto every request it proxies for the API host; api/origin_gate.py requires that header and refuses anything else with 403 before the request costs anything. It is not authentication — it says "you came through the front door", nothing about who you are. UNSET MEANS OFF, which is what makes this safe to ship first: the code can go to production long before the rule and the secret exist, local dev and the test suite never see the gate, and taking the variable off the service is the rollback. /health reports the verdict for the request it was asked with — off, off-seen, ok, missing, mismatch — never the value, so every route into the service can be measured before the switch is thrown. Exempt: /health (the deploy smoke reaches the candidate on its run.app tag URL, which never passes the edge), /seo-proxy/… (belt and braces), OPTIONS (a browser cannot attach a custom header to a preflight), and /debug/cache/invalidate — sync-postgres.yml posts there over the direct URL by design, because Cloudflare's bot challenge answers an unauthenticated curl POST with a 403 HTML page; that endpoint carries its own constant-time token. The apex Worker behind anyplot.ai/api/* gets its source into the repository at infra/cloudflare/, because a Worker subrequest to a host in the same zone bypasses that zone's Transform Rules — so it stamps the header itself, deleting any inbound one first so a caller cannot supply it and corrupt an unarmed /health probe. Its Plausible passthrough for /api/event is preserved. Two changes beyond the gate. The deploy step attaches secrets with --update-secrets instead of --set-secrets: the latter replaces the whole binding set and would have stripped ORIGIN_SECRET, silently disarming the gate on the next deploy. And the analytics middleware moves inside CORSMiddleware, because the gate has to be inside CORS (so its 403 stays readable to a browser) and outside the bot counter (so a refused request cannot fire an outbound Plausible event), which in this stack was only possible with the counter inside CORS. api/main.py now documents the whole order. Transferred from the sibling repo kurrentschrift, where this shipped and was measured live. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive cross-system rollout retains a DB-backed direct-origin bypass and needs configuration hardening and human operational review.
Pull request overview
Adds a Cloudflare-stamped shared-secret gate to prevent direct Cloud Run API access.
Changes:
- Adds the origin-gate middleware, health diagnostics, configuration, and tests.
- Updates Cloud Run deployment and Cloudflare Worker behavior.
- Documents rollout, rollback, and infrastructure changes.
File summaries
| File | Description |
|---|---|
.env.example |
Documents ORIGIN_SECRET. |
CHANGELOG.md |
Records security and deployment changes. |
api/cloudbuild.yaml |
Preserves and supplies the origin secret. |
api/main.py |
Registers and orders the middleware. |
api/origin_gate.py |
Implements the shared-secret gate. |
api/routers/health.py |
Reports the gate verdict. |
core/config.py |
Adds and normalizes secret configuration. |
docs/development.md |
Documents the environment variable. |
docs/reference/api.md |
Documents gate behavior and exemptions. |
docs/reference/repository.md |
Adds the infrastructure directory. |
infra/cloudflare/README.md |
Documents Worker deployment and rollout. |
infra/cloudflare/anyplot-api-proxy.js |
Stamps the secret on Worker subrequests. |
tests/unit/api/test_origin_gate.py |
Tests gate states, exemptions, and ordering. |
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 4
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🟡 Changes recommended
The SEO exemption retains a costly direct-origin bypass, and the documented Worker deployment exposes its API token through process arguments.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
api/origin_gate.py:75
- The
/seo-proxyexemption keeps a substantial direct-origin bypass open. A caller can hit arbitrary/seo-proxy/{spec_id}URLs onrun.app, causing database lookups (api/routers/seo.py:1288-1296), and a spoofed crawler user agent also creates an outbound Plausible request (api/main.py:213-215,api/analytics.py:400-414) without passing Cloudflare's WAF or cache. This recreates the denial-of-wallet path the gate is intended to remove; require the origin header for this prefix after validating the nginx route during rollout, rather than exempting the entire SEO surface.
EXEMPT_PATHS = frozenset({"/health", "/seo-proxy", "/debug/cache/invalidate"})
EXEMPT_PREFIXES = ("/seo-proxy/",)
docs/reference/repository.md:137
- Adding a top-level
infra/directory here leaves the corresponding directory structure inagentic/docs/project-guide.md:186-206stale. The repository documentation contract requires structural project changes to update that agent guide too, so addinfra/and its Cloudflare role there in this PR.
├── infra/ # Infrastructure that lives outside the code
│ └── cloudflare/ # Source for the apex Worker + its rollout notes
- Files reviewed: 13/13 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review, four findings, all applied. 1. The /seo-proxy exemption was a real bypass, not belt and braces. Those handlers are not uniformly cheap: a cache miss or an unknown id queries SpecRepository/ImplRepository, and any request with a recognized crawler user agent schedules an outbound Plausible event. Exempting them left the API's most expensive reads open on the direct run.app URL - exactly the cost the gate exists to refuse. The site's nginx already fetches those pages over api.anyplot.ai, so the path carries the header; the rollout validates it end to end with a crawler user agent before arming, and bot-serving-check.yml runs daily, so being wrong is loud. The exemption list is now exact paths only, no prefixes. 2. secrets.compare_digest raises TypeError when either str holds a non-ASCII character, and a header value reaches the middleware latin-1-decoded straight from the wire - so one byte >= 0x80 in X-Origin-Secret turned every refusal into an unhandled 500, an unauthenticated way to make the gate expensive. Both comparisons now encode first, which also covers a non-ASCII secret at the other end. 3. The /health example in docs/reference/api.md still showed version 0.2.0, as did the root endpoint two blocks above it; both now show the real version, with a note that the field tracks the installed package. 4. agentic/docs/project-guide.md's directory structure did not list the new infra/ tree. It does now, next to the same entry in docs/reference/repository.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot review: the Worker-deploy recipe passed the credential as -H "Authorization: Bearer $CF_API_TOKEN", which publishes it in /proc/<pid>/cmdline for the life of the request - to every process on the machine, and for the token that authorises replacing this Worker. The ORIGIN_SECRET handling in the same block already avoided exactly that. It now goes in through curl --config on a process-substitution file descriptor, with printf as a shell builtin, so the token reaches no argv at all. Noted that this needs bash or zsh, with the 600-mode temporary file as the fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
It changes production ingress security and manual Cloudflare and Cloud Run rollout behavior that cannot be fully validated from the repository alone.
Review details
Suppressed comments (1)
api/origin_gate.py:163
- Once this middleware is armed, the existing break-glass contract in
api/routers/debug.py:75-77is incomplete:X-Admin-Tokenalone on the direct Cloud Run URL now receives this 403, and the new test confirms that both headers are required. Update that operator-facing guidance to requireX-Origin-Secretas well, otherwise incident responders are directed to a path guaranteed to fail.
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or not gate_is_armed() or is_exempt(scope["path"], scope["method"]):
await self.app(scope, receive, send)
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Balanced
Copilot review: core/config.py's origin_secret docstring listed /seo-proxy/... among the exempt paths, which is what it was before the exemption was removed earlier in this PR. An operator reading only the settings file could arm the gate expecting crawler traffic to be waved through. It now names the two real exemptions and says explicitly that the prerendered pages are not among them. The PR description raised in the same review was already corrected before this push; the review read the pre-edit body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive cross-system rollout has unresolved secret-normalization and exempt-endpoint robustness issues.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
core/config.py:211
- This validator changes the canonical
ORIGIN_SECRET, but the new stampers use the raw Secret Manager value (api/cloudbuild.yaml:137-139andinfra/cloudflare/README.md:96-106). A space-padded value explicitly accepted by the new tests therefore makes the Worker/edge send a different value and makes the smoke reportmismatch. Either reject surrounding whitespace here or normalize the secret identically before every header is stamped.
core/config.py:180
- This docstring still says
/seo-proxy/…is exempt, contradictingEXEMPT_PATHSand the updated API documentation. Remove it from the list so future maintainers do not reintroduce the bypass that this revision intentionally closed.
Local dev and the test suite never set it. Exactly two paths stay exempt
when it is set — `/health` and `/debug/cache/invalidate`; the prerendered
`/seo-proxy/…` pages are NOT among them, since the site's nginx fetches
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
The deployment can still remove ORIGIN_SECRET, and two malformed-secret paths can cause production failures.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
infra/cloudflare/anyplot-api-proxy.js:24
origin_secretaccepts arbitrary Unicode, but the WorkersHeaders.setAPI converts values toByteStringand throws for code points above U+00FF. A CJK or emoji secret that Cloud Run accepts will therefore make everyanyplot.ai/api/*request fail in the Worker before reaching the API. Restrict and document the secret as a strong ASCII token (with a generation command), or apply one consistent transport encoding in the Worker and API.
infra/cloudflare/README.md:123- Use the repository documentation's American spelling: “authorizes.”
api/origin_gate.py:80
- This exemption leaves the same malformed-header failure that
_matchesavoids for the new gate.invalidate_cachestill compares Starlette's Latin-1-decodedX-Cache-Tokenasstr(api/routers/debug.py:487-492), so a raw byte ≥ 0x80 raisesTypeError; because this exact path bypasses the origin gate, an unauthenticated direct-origin caller can force a logged 500 on every request. Compare bytes there too and add the corresponding raw-header regression test before relying on this endpoint as the alternate lock.
EXEMPT_PATHS = frozenset({"/health", "/debug/cache/invalidate"})
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
Merge resolves the CHANGELOG conflict by keeping both Added entries (#11205 landed on main while this branch was open). Copilot review, two findings. 1. /debug/cache/invalidate is exempt from the gate on the grounds that it has its own lock - so that lock has to be as cheap to fail as the gate is, and it was not: it compared the token as str, and it is the one endpoint reachable on the direct run.app URL, so a non-ASCII X-Cache-Token turned a 401 into an unhandled, logged 500. X-Admin-Token had the same comparison. Rather than fixing three call sites separately, the byte-wise compare moves into api/secret_compare.py and all three use it; a comparator that is correct in two places out of three is exactly what nobody notices. 2. --set-env-vars is destructive over the revision environment, where a secret-backed variable lives alongside a literal one - so it was a second way for a pipeline deploy to drop the hand-attached ORIGIN_SECRET and disarm the gate, undoing what --update-secrets had just bought. Both flags are additive now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
The security boundary depends on coordinated, live Cloudflare and Cloud Run configuration that cannot be verified from the repository alone.
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Balanced
CHANGELOG only; both sides add entries under Added and both are kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Copilot review: the rollout used 'update-traffic --to-latest' for both arming and rollback - the exact flag api/cloudbuild.yaml refuses, and for the same reason. The deploy pipeline leaves each build's smoked-but-unpromoted candidate as the latest revision, so --to-latest can promote a concurrent build's image while the operator believes they are only turning the gate on or off. Both procedures now stamp a deterministic --revision-suffix on the service update and promote exactly that revision with --to-revisions. The commands move into docs/reference/api.md so the procedure outlives the PR description, and the Worker README points at them instead of paraphrasing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
The security-sensitive rollout can promote an untested image and produce inconsistent gate secrets across Cloud Run instances.
Review details
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
api/origin_gate.py:9
- The cited documentation at
api/request_context.py:22-25still says directrun.appcallers can forgecf-connecting-ip. Once this gate is armed, that is no longer true for ordinary routes (and the exempt cache invalidation route does not use this helper). Update that docstring to describe the gate-off/legacy case so future security work does not treat a closed bypass as an active trust limitation.
api/origin_gate.py:146 - These new middleware methods omit the type hints required for all functions in this repository. Add
ASGIApp,Scope,Receive, andSendannotations (including-> None) so the middleware participates in static checking instead of making mypy skip its request path.
infra/cloudflare/README.md:64 - The runbook never records the Transform Rule's reproducible configuration, although arming depends on it and this directory is intended to capture dashboard-only infrastructure. Document the host expression, Modify Request Header action, static
X-Origin-Secretassignment, and shared-value requirement so restoring the Cloudflare setup from the repository cannot arm the API without its front door.
infra/cloudflare/README.md:135 - This initial deployment is intentionally performed before the gate is armed, so a correctly stamped Worker returns
off-seen, notok. The current expected value makes the documented rollout appear to fail at the exact checkpoint meant to validate it.
- Files reviewed: 16/16 changed files
- Comments generated: 2
- Review effort level: Balanced
… secret Copilot review, two more on the same procedure, both correct. 1. 'services update' clones the service's LATEST template, not the serving one, and the deploy pipeline deliberately leaves each build's smoked-but-unpromoted candidate as latest. Arming during a deploy would therefore promote that build's image along with the gate - naming the new revision precisely does not change which image it inherits. The block now asserts that latestReadyRevisionName equals the revision serving 100% and refuses otherwise, the same shape of assertion the cloudbuild smoke makes about its own candidate tag. 2. ORIGIN_SECRET:latest floats. Cloud Run resolves a secret-backed variable when each instance starts, so a new version reaches new instances while older ones keep the old value - and because the edge stamps exactly one value, that surfaces as intermittent 403s inside a single revision. The binding is pinned to a resolved version number. Rotation gets its own paragraph, because the gate accepts exactly one value and there is no overlap window: roll back, rotate both sides, arm again on the new number. The gate is off in between, which is the documented safe state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
The security and deployment changes require human rollout validation, and the Worker documentation contains a contradictory pre-arm health expectation.
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review: the Worker README told the operator to expect 'origin_gate':'ok' straight after deploying the Worker, which contradicts the rollout order three sections above it - at that point the API gate is deliberately still off, so a correctly stamped request answers 'off-seen'. Read literally, the required pre-arm measurement looked like a failed deployment, and the obvious reaction to it would have been to arm the gate early. The command now lists all three reachable verdicts with what each one means at that moment, including that 'off' is the one that says the binding is missing and arming would take the route down. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🟡 Changes recommended
The app origin still relays gated requests, and the documented arm procedure can fail open or race a concurrent deployment.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
docs/reference/api.md:498
- This safety check fails open if
gcloud run services describeor the JSON parser fails. The here-string still givesreadan empty line, so both variables become empty andtest "" = ""succeeds; this block has no fail-fast wrapper. The subsequent update can then clone the candidate that this check was meant to exclude. Capture and validate the command status/output (or run the full procedure in a fail-fast subshell) before comparing nonempty revision names.
docs/reference/api.md:518 - The earlier equality check is subject to a deployment race: a Cloud Build can create a new no-traffic candidate after line 498 but before this command, and
services updatewill then clone that new latest template. Naming the arm revision does not prevent it from inheriting and promoting the unsmoked image. Pin the update to the image digest captured fromSERVING, introduce mutual exclusion with deployments, or verify the created revision against the captured serving revision before shifting traffic.
api/origin_gate.py:146 - Both new middleware methods are untyped, contrary to the repository requirement that all functions include type hints. Annotate the constructor with
ASGIAppand-> None, and the call hook with Starlette'sScope,Receive,Send, and-> None; this also lets mypy check the raw ASGI boundary.
- Files reviewed: 16/16 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot review, and it is correct as an observation: the app service also stands with ingress=all, and its nginx relays a crawler user agent through @seo_proxy to https://api.anyplot.ai, where the edge stamps the header legitimately. So a caller can still reach the prerendered render and its DB queries by asking the APP's raw run.app URL instead of the API's. That is a second door on a second service, not a hole in this one - the request this process sees really did come through the edge, so there is nothing for this middleware to refuse. Closing it means gating the app service or refusing to proxy for run.app hosts in app/nginx.conf, and bot-serving-check.yml probes that exact flow on the app origin every night, so it carries its own blast radius and belongs in its own PR. Named in api/origin_gate.py and docs/reference/api.md rather than left for someone to rediscover, because a gate whose limits are undocumented gets trusted past them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🟡 Changes recommended
The blanket OPTIONS exemption permits direct-origin analytics traffic, and the rollback procedure can become unavailable after a failed or concurrent deployment.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
api/origin_gate.py:108
- The blanket
OPTIONSexemption admits more than CORS preflights.CORSMiddlewareonly consumes anOPTIONSrequest when it includesOriginandAccess-Control-Request-Method; a bare direct-originOPTIONS /seo-proxy/legalreaches this branch, passes the gate, and thenrecord_bot_fetchemits a Plausible event for a crawler user agent (api/main.py:211-215). This recreates the unthrottled third-party cost the middleware ordering is intended to prevent. Since CORS is already outside the gate, remove the method-wide exemption, or restrict it to an actual preflight by checking the required headers.
- Files reviewed: 16/16 changed files
- Comments generated: 2
- Review effort level: Balanced
…th headers Copilot review, two findings. 1. The in-flight guard could have made the emergency rollback unavailable. A failed smoke skips the promote and nothing cleans the candidate up, so the failed revision stays latestReadyRevisionName indefinitely and SERVING == LATEST would never become true by waiting - a hard refusal there would deadlock disarming at exactly the moment it is needed, and the check was racy besides. The block now pins --image to the SERVING revision's image instead, which makes the operation correct whatever state the pipeline is in, and demotes the equality test to a warning that says what a mismatch still means (an unpromoted config change). A step 4 was added to confirm the result, since a warning nobody reads is not a safeguard. 2. require_admin's docstring still promised that X-Admin-Token alone gets an operator in over the direct URL. With the gate armed that call needs BOTH headers, because the origin gate answers first - and it answers 403, not 401, which is the only hint an operator would get. Documented where they will read it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Summary
api/origin_gate.pyrequires the header the Cloudflare edge stamps, and refuses anything else with403before the request costs anything. The API stands on Cloud Run withingress=all, so it answers on two addresses —api.anyplot.aibehind Cloudflare, and the raw*.run.appURL in front of nothing. Every edge measure (bot challenge, WAF, the cache that makes themax-age=300reads free) was one URL away from being bypassed;api/request_context.pyalready documented callers doing it.ORIGIN_SECRETis 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./healthreportsorigin_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.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.ingress=all, and its nginx relays a crawler user agent through@seo_proxytoapi.anyplot.ai, where the edge stamps the header legitimately — so the prerendered render stays reachable via the app's rawrun.appURL. That is a second door on a second service (the request the API sees really did pass the edge), and closing it means gatinganyplot-appor refusing to proxy forrun.apphosts, whichbot-serving-check.ymlprobes nightly. Named inapi/origin_gate.pyanddocs/reference/api.md; own PR.What is exempt, and why each one has to be
Exact paths, no prefixes:
/healthrun.apptag URL, which by definition never passes the edge — gating it makes every deploy fail closed/debug/cache/invalidatesync-postgres.ymlposts here from a GitHub runner over the direct*.run.appURL on purpose — Cloudflare's bot challenge answers an unauthenticated curl POST againstapi.anyplot.aiwith 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 lockOPTIONS/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 querySpecRepository/ImplRepositoryon 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 overhttps://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, andbot-serving-check.ymlruns 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/eventPlausible passthrough is preserved untouched.anyplot-api-…run.app— the Cloud Build smoke (now sends the header) andsync-postgres.yml(exempt path, above).anyplot-app-…run.app—bot-serving-check.ymlhits the app origin, whose nginx then goes out throughapi.anyplot.ai. Unaffected.Every header secret goes through one byte-wise comparator
secrets.compare_digestraisesTypeErrorwhen eitherstrholds 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-TokenandX-Cache-Token, which matters more:/debug/cache/invalidateis exempt from the gate on the grounds that it has its own lock, and it is the one endpoint reachable on the directrun.appURL. So the fix is one comparator inapi/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
strcomparison 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-secretsand--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_SECRETis 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 inapi/cloudbuild.yamlis 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_fetchfires 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 itssetdefaultfor the/og/cards depends on being.api/main.pynow 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_agentclassifies 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_SECRETdoes not exist yet,gate_is_armed()is false, every path behaves exactly as today. Confirm with: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-seenon EVERY path. Cloudflare dashboard: Rules → Transform Rules → Modify Request Header → set staticX-Origin-Secretforhttp.host eq "api.anyplot.ai". Then deployinfra/cloudflare/anyplot-api-proxy.jswith theORIGIN_SECRETsecret binding (procedure ininfra/cloudflare/README.md). The gate is still off, so nothing can break; what this step buys is the evidence: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.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:services updateclones 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 assertslatestReadyRevisionName == the revision serving 100%and stops otherwise.:latest. Cloud Run resolves a secret-backed variable when each instance starts, so with:latesta 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.--to-latest— the same hazard as (1), and the reasonapi/cloudbuild.yamlrefuses 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.
Then walk the site once (gallery, a spec page, the stats page), fetch an OG card cross-origin, and let one
sync-postgresrun finish — its cache flush must still return 200.(e) Rollback — one variable, no code change:
The same block as arming, with
--remove-secrets=ORIGIN_SECRETin place of--update-secretsand adisarm-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/invalidateis exempt becausesync-postgres.ymlhas no front door. The cleaner end state is for that workflow to sendX-Origin-Secretfrom 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
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/healthverdicts, the non-ASCII header on all three secrets, and the trailing-newline strip onORIGIN_SECRETand the other Secret-Manager-backed values.uv run pytest tests/unit— 1818 passed, 1 skipped (pre-existing local skip: MonoLisa italic not cached).uv run ruff check ./ruff format --check .— clean.uv run --extra typecheck mypy api core— no issues in 37 source files.api/cloudbuild.yamlparses; step ids unchanged (build-image,push-image,push-latest,deploy,smoke,promote,get-url)./librariesonce it is on — and it acceptsoff/off-seen, so it cannot take the deploy pipeline down during the rollout or after a rollback.Checklist
CHANGELOG.mdupdated under[Unreleased]— one### Addedentry for the gate, two### Changedentries for the deploy flag and the middleware order.docs/reference/api.md(new "Origin gate" section,/healthresponse),docs/development.md(env table),docs/reference/repository.mdandagentic/docs/project-guide.md(both repository maps getinfra/),.env.example, andinfra/cloudflare/README.mdfor the Worker and the measuring procedure.🤖 Generated with Claude Code
https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke