Repository navigation
fix(infra): least-privilege deploy tokens, HTTPS and security headers - #22
Open
reecelikesramen wants to merge 1 commit into
Open
reecelikesramen wants to merge 1 commit into
reecelikesramen wants to merge 1 commit into
Conversation
Deploys and Terraform no longer share one broad Cloudflare token that any branch could read: - deploy.yml builds without secrets (pnpm install --ignore-scripts) and uploads site/dist; production deploys only download it and run a pinned wrangler with a Pages-only token from the main-only `production` environment. - PR previews deploy from the new deploy-nightly.yml (workflow_run, so main's copy of the workflow runs) with the Pages-only token from the main-only `nightly` environment. - infra-plan.yml uses a read-only Cloudflare token and read-only state keys with -lock=false; infra-apply.yml runs in `production` behind a job-level main guard instead of a removable step. - Least-privilege permissions everywhere, actions pinned to commit SHAs, Dependabot for github-actions, and CODEOWNERS gets a `*` pattern. The router Worker redirects http:// and www. to https://pywire.dev, always proxies to Pages over HTTPS and rewrites any pages.dev Location, which fixes http://pywire.dev/install redirecting to pywire-landing.pages.dev and the http://pywire.dev/docs/ redirect loop. It reads the Pages hostnames from Terraform bindings and adds HSTS, nosniff, Referrer-Policy, Permissions-Policy, framing rules and a CSP (report-only for the docs tutorial beyond frame-ancestors/object-src/base-uri). Terraform turns on always_use_https, routes www to the router, and protects both Pages projects with prevent_destroy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018u8icLkZGUvTWLJ6Faa89E
Terraform plan — ❌ error�[0m�[1mInitializing the backend...�[0m
�[31m╷�[0m�[0m
�[31m│�[0m �[0m�[1m�[31mError: �[0m�[0m�[1mNo valid credential sources found�[0m
�[31m│�[0m �[0m
�[31m│�[0m �[0m�[0mPlease see https://developer.hashicorp.com/terraform/language/backend/s3
�[31m│�[0m �[0mfor more information about providing credentials.
�[31m│�[0m �[0m
�[31m│�[0m �[0mError: failed to refresh cached credentials, no EC2 IMDS role found,
�[31m│�[0m �[0moperation error ec2imds: GetMetadata, access disabled to EC2 IMDS via
�[31m│�[0m �[0mclient option, or "AWS_EC2_METADATA_DISABLED" environment variable
�[31m│�[0m �[0m
�[31m╵�[0m�[0m |
This branch has not been deployed
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.
Fixes the pywire.dev findings from the security review: H5, M17, L16, L17, L18, plus the pywire.dev part of the hardening note about tag-pinned actions that hold tokens.
H5: one broad Cloudflare token that every branch could read
deploy.ymlbuild: runspnpm install --frozen-lockfile --ignore-scriptsand the PR codesite/distas an artifactdeploy.ymldeploy-production(push to main)productionCLOUDFLARE_PAGES_TOKENdeploy-nightly.yml(new)deploy-nightly(workflow_runafter a PR build)nightlyCLOUDFLARE_PAGES_TOKENinfra-plan.ymlplan(PRs, main, weekly)CLOUDFLARE_READONLY_TOKEN+ read-only R2 keys,-lock=falseinfra-apply.ymlapply(manual)production+ job-levelif: github.ref == 'refs/heads/main'CLOUDFLARE_API_TOKEN+ read/write R2 keyswrangler(4.145.0).gitHubTokenanddeployments: writeare removed, because the environment already records deployments.workflow_run. That trigger always runs the copy of the workflow onmain. A PR can change what gets built for nightly, but it can't change what the job does with the token. So thenightlyenvironment can also be limited tomain.if. The old step that did this could be deleted in a branch.permissions:everywhere. Workflows default to{}. Checkouts usepersist-credentials: false. CI also installs with--ignore-scripts, so it matches the deploy build.infra/README.mddocuments the new secrets, environments, exact token scopes and why each exists.M17: HTTPS, HSTS and redirects that leaked pages.dev
cloudflare_zone_setting.always_use_https = "on".http://→https://(301) before doing anything else.Locationthat points at the upstream or any*.pages.devhost back to the public origin, keeping the mount prefix.Strict-Transport-Security: max-age=31536000; includeSubDomains(nopreload).http://pywire.dev/installredirected tohttps://pywire-landing.pages.dev/install.http://pywire.dev/docs/looped. Pages' own http→https redirect was rewritten back to/docs/over http.L16: action pins, Dependabot, CODEOWNERS
# vX.Y.Zcomment. SHAs were resolved withgit ls-remote, using peeled SHAs for annotated tags:.github/dependabot.ymlforgithub-actions: weekly, grouped,choreprefix.CODEOWNERSnow reads* @pywire/maintainers. Before, it had the team but no pattern.L17: fragile Pages resources and hostnames
prevent_destroy: bothcloudflare_pages_projectresources now havelifecycle { prevent_destroy = true }. The README's recreate procedure is updated to match.LANDING_HOST/DOCS_HOSTfromplain_textbindings set fromcloudflare_pages_project.*.subdomain. Nightly adds thenightly.prefix..subdomainnow. The value is the same, so there's no DNS change.cloudflare_workers_route.www(www.pywire.dev/*→ router), and the router 301swww.to the apex. It currently returns 525. The existing proxiedwwwDNS record isn't in Terraform, and I don't know its record id, so I haven't imported it. The README gives the import steps.L18: security headers (added by the router)
/docs/*)/cdn/*nosniff,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policyframe-ancestors 'self'; object-src 'none'; base-uri 'self'default-src 'none'; frame-ancestors 'none'; sandboxX-Frame-OptionsDENYSAMEORIGINDENY'self' 'unsafe-inline'. Astro inlines small scripts.img-src 'self' data: https:frame-src https://www.youtube-nocookie.comform-action 'self',frame-ancestors 'none',upgrade-insecure-requests'wasm-unsafe-eval'/cdn, pypi.org and files.pythonhosted.orgapplication/x-install-instructionscontent type.docs.pywire.devis served by Pages directly and gets none of these headers. See the README.Validation
node --test 'tests/*.test.mjs': 21/21 pass. That includes the newtests/worker.test.mjs, which has 14 router tests: http/www redirects, no pages.dev in any header,/docsmount rewrites, nightly routing, headers on each response type, CDN index/files, and failing closed when bindings are missing. CI'sInstaller Testsjob already runstests/*.test.mjs../scripts/check(prettier, eslint, tsc): pass. The site builds withpnpm install --frozen-lockfile --ignore-scripts.terraform validate(1.16.4, cloudflare provider 5.16.0 from the lockfile): valid.terraform fmt -check main.tf: clean.infra.auto.tfvarshas a formatting difference that was already on main; I left it alone.actionlint: clean on all workflows./cdn, and rendered the preview iframe with zero report-only or enforced CSP reports. As a control, a stricter policy reported blob: workers, WebAssembly anddata:images. Only the first tutorial step was exercised, and the service worker was blocked in the harness, so the full docs policy stays report-only for now.Manual steps for the owner before merging
pywire.dev: Email Routing Rules, Workers Routes, DNS, Zone WAF, Zone Settingspywire-tfstate.terraform plan -lock=falsemust succeed and match a plan made with the write token.production(Settings → Environments):mainonly. Required reviewers are optional; they would gate apply and site deploys.CLOUDFLARE_PAGES_TOKEN,CLOUDFLARE_API_TOKEN(the existing broad token, or a new one with the same grants),R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY.nightly:mainonly.workflow_runruns on main.CLOUDFLARE_PAGES_TOKEN.CLOUDFLARE_READONLY_TOKEN,R2_READONLY_ACCESS_KEY_ID,R2_READONLY_SECRET_ACCESS_KEY.CLOUDFLARE_ACCOUNT_ID,EMAIL_FORWARDING_RULESandMAINTAINER_EMAILSstay as repository secrets.CLOUDFLARE_API_TOKEN,R2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEY. Every branch can read repository secrets. Consider rotating the broad token, since PR branches could read it until now.@pywire/maintainersexists and has write access, so the CODEOWNERS rule applies.always_use_https, the www route andprevent_destroy. Then spot-check:curl -I http://pywire.dev/install→ 301 tohttps://pywire.dev/installcurl -I https://www.pywire.dev/→ 301 to the apex[Report Only]messages, moveDOCS_CSP_REPORT_ONLYinto the enforced header inworker/src/index.js.The Infra plan comment on this PR will show an error until
CLOUDFLARE_READONLY_TOKENand the read-only R2 keys exist. That's expected, and it never fails the PR.pywire/pywire's
deploy-docs.ymlalso deploys with a Cloudflare token. That's out of scope here, but it should get the same treatment: a Pages-only token in a main-only environment.🤖 Generated with Claude Code
https://claude.ai/code/session_018u8icLkZGUvTWLJ6Faa89E
Generated by Claude Code