feat(delivery): phase 12 — the container, and the defect only a proxy could find #17
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/phase-12-delivery"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
PLAN.md §13 phase 12 — delivery, and the last phase. Four decisions of record, D54–D57, taking the count to fifty-seven; recorded in §6, "How phase 12 delivered it".
A two-stage
Dockerfile, a pull-onlydocker-compose.ymlcarrying both bind mounts,.env.example, the workflow that publishes and deploys,CONTRIBUTING.md, the community-health files this was the only repository of the ten to lack, andDEPLOY.md.It also found a defect that would have made the closed beta impossible, and it is the only phase that could have found it. Every phase before this ran the site the way a developer runs it: one process, plain HTTP, an origin the browser and the server agree about by construction. The site does not run that way, and standing a real container behind a real proxy had a different answer.
D56 — the beta signup was dead behind a TLS-terminating proxy
@astrojs/nodebuilds the URL of every request from the connection and theHostheader alone. Inastro/app/node'screateRequestFromNodeRequest:x-forwarded-protois never consulted on that path — andsecurity.allowedDomains, which sounds like the answer, is not: on this code path it gates only whetherAstro.clientAddressmay come fromx-forwarded-for. So behind a proxy that terminates TLS the browser sendsOrigin: https://runicgateway.comand the container computeshttp://runicgateway.com, because its own socket is plaintext. Astro's CSRF middleware then compares the two for equality:Every beta signup, from every visitor, answered
403 Cross-site POST form submissions are forbidden— on the one route that accepts a POST, on the site whose nearest real deadline is a closed test that cannot start without it. No proxy configuration fixes it; a proxy cannot make the container's socket encrypted. Fifty pages look perfectly healthy while the form silently refuses everyone.scripts/serve.mjsalready exists to wrap this adapter's mistakes (D48), so the fix went there:x-forwarded-proto: httpsmarks the socket encrypted, andx-forwarded-hostreplacesHostfor the proxies that rewrite it to the upstream address rather than passing it through.Both are trusted unconditionally, with no flag to set — your call, and the right one. The image is meant to be deployed and work; an operator who has to discover a
TRUST_PROXYvariable to make the signup work is an operator who ships a dead form, and the broken configuration would be the default. It costs nothing: a cross-site form submission cannot make a victim's browser sendx-forwarded-proto, so the CSRF check is exactly as strong as it was, and the site has no cookie, session or credential to protect in the first place. Two assertions intest/headers.test.mjshold both halves — a proxy-shaped POST is accepted, and a genuinely cross-origin one is still refused.D54 — a merge deploys, which amends D6
D6 said "ship the image and the compose file; the org lead deploys", and it was written before there was a host to deploy to.
build-image.ymlnow builds, pushesrunicgateway-site:latestand:sha-<7>, and rolls the container over on a runner labelledrgcomout of/opt/runicgateway.com. There is no release step and no promotion, so a merge is a publication.What D6 was protecting is held by something else: every check and both test suites have already run on the pull request, and
needs: buildmeans a failed build never reaches the host. The deploy job then waits for the container's own healthcheck rather than forup -dto return —npm startruns the brand rewrite before the server starts, so "running" arrives well before "serving", and without the wait the job would go green on a container about to crash-loop. A pinnedIMAGE_TAGsurvives an automatic deploy, because a pin is a decision and a merge should not quietly undo it.The runner registers as
rgcom:host. The:hostsuffix is what makes jobs run on the machine rather than in a job container; without it the deploy fails on thecd, having no Docker socket and no compose directory.DEPLOY.md§7 has the registration command.D55 — a generic proxy, documented by its requirements
The site runs on its own host behind whatever proxy you put there, so
DEPLOY.mdstates the four things the proxy must do rather than a Caddyfile that would be wrong for three readers out of four, and the container binds127.0.0.1by default so the safe configuration is the default one. Two of the four are silent when wrong:X-Forwarded-Foris load-bearing. The signup rate-limits from the first entry of that header, falling back to the peer address. Behind a proxy that does not set it, the peer address is the proxy — every visitor shares one bucket, and the third signup of any hour closes the form for everybody. It fails toward refusing signups rather than toward accepting abuse, which is the right direction and still a broken page.D57 — the operator note is its own file, and two policies are pointers
DEPLOY.mdrather than a README section: the README is for someone working on the site and was already long; deployment is a different task for a different sitting, and it is the file that gets opened on the host. Ten sections — what the site needs, first deploy, the proxy, DNS and TLS, branding without a rebuild, the tester-list CLI, updating, rolling back, backups, and a symptoms table.This repository was also the only one of the ten with no
CONTRIBUTING.md,CODE_OF_CONDUCT.md,SECURITY.mdor issue/PR templates. The two policy files are pointers to the org's copies indocs, not copies — a copy would hard-code the contact address in a tenth place, and D13's whole promise is that the address lives only inbrand.json.checkFacts.mjsscanssrc/andscripts/; these honour the same rule voluntarily and say so, so nobody later "fixes" the missing address.SECURITY.mdis not only a pointer, though: it names what is worth reporting here — the signup's store, rate limit and signed form token; traversal out of the branding mount; a page served no policy or another page's — and states that there is no authenticated surface to attack.No twelfth check. D53 was re-tested against this phase's new drift risk (the env vars the code reads vs
.env.examplevs the README table) and you held the line. Eleven checks stand.What was actually run
npm run verify, and then the part no script does — the image built, and run twice: once directly with both mounts, once through the realdocker-compose.yml.In the container, against the mounts:
npm prune --omit=devbetween themdocker stopreturns in 1.1s/modules/and/docs/modules/building-a-moduleserved different policies — the D48 workaround holds inside the image/betaand/brand/*getframe-ancestors 'none'; all four non-CSP headers presentbrand.jsonreached 51 files and re-indexed all 50 search pages;/betafollowed it too (D51 holds)theme.cssserved asx-brand-source: mountwhilelogo.pngstayeddefaultand the faviconderived:defaultbetaOptInUrlHost+X-Forwarded-Proto: https+Origin: https://…) → stored, where it was 403 before the fixog:urlhttps://…on the per-request page, which it also could not have beendata/exports/2026-08-25.csvand.txtto the host bind mountVACUUM INTOone-liner runs against the live storedocker compose configearned its place in the walk. The healthcheck was written as a block sequence, and YAML reads the:insider.ok ? 0 : 1as a key/value separator:services.site.healthcheck.test.3 must be a string. It is a quoted flow sequence now, with a comment saying why.What is left, and it is not a phase
Point the DNS record at the host (§14, N1), start the
rgcomrunner, and — when the demo VM exists (§15) and the Play track is open — put two URLs into the mountedbrand.json. None of those is a code change, which was the point.Co-Authored-By: Claude.🤖 Generated with Claude Code