Skip to content

fix(infra): least-privilege deploy tokens, HTTPS and security headers - #22

Open
reecelikesramen wants to merge 1 commit into
mainfrom
claude/artifact-review-1q594l
Open

reecelikesramen wants to merge 1 commit into
mainfrom
claude/artifact-review-1q594l

Conversation

@reecelikesramen

Copy link
Copy Markdown
Contributor

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.

⚠️ Do the manual steps at the bottom before merging. After merge, Infra apply and the deploys look for secrets in the new environments. If those environments don't exist yet, the jobs fail.

H5: one broad Cloudflare token that every branch could read

Workflow Job Environment Credential
deploy.yml build: runs pnpm install --frozen-lockfile --ignore-scripts and the PR code — none. It uploads site/dist as an artifact
deploy.yml deploy-production (push to main) production CLOUDFLARE_PAGES_TOKEN
deploy-nightly.yml (new) deploy-nightly (workflow_run after a PR build) nightly CLOUDFLARE_PAGES_TOKEN
infra-plan.yml plan (PRs, main, weekly) — CLOUDFLARE_READONLY_TOKEN + read-only R2 keys, -lock=false
infra-apply.yml apply (manual) production + job-level if: github.ref == 'refs/heads/main' CLOUDFLARE_API_TOKEN + read/write R2 keys
  • Deploy jobs never install anything from the repo. They download the artifact from the build job, which has no secrets, and run a pinned wrangler (4.145.0). gitHubToken and deployments: write are removed, because the environment already records deployments.
  • PR previews use workflow_run. That trigger always runs the copy of the workflow on main. A PR can change what gets built for nightly, but it can't change what the job does with the token. So the nightly environment can also be limited to main.
  • The main-only guard for apply is now the environment, plus a job-level if. The old step that did this could be deleted in a branch.
  • Least-privilege permissions: everywhere. Workflows default to {}. Checkouts use persist-credentials: false. CI also installs with --ignore-scripts, so it matches the deploy build.
  • infra/README.md documents the new secrets, environments, exact token scopes and why each exists.

M17: HTTPS, HSTS and redirects that leaked pages.dev

  • Terraform: cloudflare_zone_setting.always_use_https = "on".
  • Router Worker:
    • Redirects http:// → https:// (301) before doing anything else.
    • Always proxies to Pages over HTTPS.
    • Rewrites any Location that points at the upstream or any *.pages.dev host back to the public origin, keeping the mount prefix.
    • Sends Strict-Transport-Security: max-age=31536000; includeSubDomains (no preload).
  • These fix two bugs:
    • http://pywire.dev/install redirected to https://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

  • Every action is pinned to a full commit SHA with a # vX.Y.Z comment. SHAs were resolved with git ls-remote, using peeled SHAs for annotated tags:
    • checkout v7.0.1
    • setup-node v7.0.0
    • pnpm/action-setup v6.1.0
    • wrangler-action v4.1.3
    • setup-terraform v4.0.1
    • github-script v9.0.0
    • upload-artifact v7.0.1
    • download-artifact v8.0.1
  • New .github/dependabot.yml for github-actions: weekly, grouped, chore prefix.
  • CODEOWNERS now reads * @pywire/maintainers. Before, it had the team but no pattern.

L17: fragile Pages resources and hostnames

  • prevent_destroy: both cloudflare_pages_project resources now have lifecycle { prevent_destroy = true }. The README's recreate procedure is updated to match.
  • No hardcoded pages.dev names:
    • The Worker reads LANDING_HOST / DOCS_HOST from plain_text bindings set from cloudflare_pages_project.*.subdomain. Nightly adds the nightly. prefix.
    • If a binding is missing, the Worker returns 500 instead of guessing a hostname.
    • The docs CNAME also uses .subdomain now. The value is the same, so there's no DNS change.
  • www: there is a new cloudflare_workers_route.www (www.pywire.dev/* → router), and the router 301s www. to the apex. It currently returns 525. The existing proxied www DNS 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)

Landing + installer Docs (/docs/*) /cdn/*
HSTS, nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy ✓ ✓ ✓
Enforced CSP full policy (below) frame-ancestors 'self'; object-src 'none'; base-uri 'self' default-src 'none'; frame-ancestors 'none'; sandbox
Report-only CSP — full tutorial policy (below) —
X-Frame-Options DENY SAMEORIGIN DENY
  • Landing CSP (enforced):
    • Scripts and styles: 'self' 'unsafe-inline'. Astro inlines small scripts.
    • img-src 'self' data: https:
    • frame-src https://www.youtube-nocookie.com
    • form-action 'self', frame-ancestors 'none', upgrade-insecure-requests
  • Docs CSP (report-only) covers what the tutorial needs:
    • Pyodide from jsDelivr, via 'wasm-unsafe-eval'
    • blob: workers (Monaco and Pyodide)
    • micropip installs from /cdn, pypi.org and files.pythonhosted.org
    • a same-origin preview iframe
  • The installer keeps its application/x-install-instructions content type.
  • docs.pywire.dev is 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 new tests/worker.test.mjs, which has 14 router tests: http/www redirects, no pages.dev in any header, /docs mount rewrites, nightly routing, headers on each response type, CDN index/files, and failing closed when bindings are missing. CI's Installer Tests job already runs tests/*.test.mjs.
  • ./scripts/check (prettier, eslint, tsc): pass. The site builds with pnpm 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.tfvars has a formatting difference that was already on main; I left it alone.
  • actionlint: clean on all workflows.
  • Headless Chromium, landing site: loaded 10 pages of the built site plus a client-side navigation with the enforced landing CSP. No violations. As a control, a stricter policy did produce violations in the same harness.
  • Headless Chromium, docs tutorial: ran the live docs through this router, with the real Pages upstream. The tutorial loaded Pyodide, installed from PyPI and /cdn, and rendered the preview iframe with zero report-only or enforced CSP reports. As a control, a stricter policy reported blob: workers, WebAssembly and data: 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

  • Create a Pages-only token (Cloudflare → Account API Tokens, an account token): Account → Cloudflare Pages: Write, nothing else.
  • Create a read-only Terraform token with the same grants as the current token, but Read:
    • Account: Pages, Workers R2 Storage, Workers Scripts, Email Routing Addresses, Account Rulesets
    • Zone pywire.dev: Email Routing Rules, Workers Routes, DNS, Zone WAF, Zone Settings
  • Create read-only R2 keys: Object Read only, scoped to pywire-tfstate.
  • Check the read-only credentials locally: terraform plan -lock=false must succeed and match a plan made with the write token.
  • Create environment production (Settings → Environments):
    • Deployment branches: Selected branches → main only. Required reviewers are optional; they would gate apply and site deploys.
    • Environment secrets: 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.
  • Create environment nightly:
    • Deployment branches: Selected branches → main only. workflow_run runs on main.
    • Environment secret: CLOUDFLARE_PAGES_TOKEN.
  • Add repository secrets: CLOUDFLARE_READONLY_TOKEN, R2_READONLY_ACCESS_KEY_ID, R2_READONLY_SECRET_ACCESS_KEY. CLOUDFLARE_ACCOUNT_ID, EMAIL_FORWARDING_RULES and MAINTAINER_EMAILS stay as repository secrets.
  • Delete the repository-level CLOUDFLARE_API_TOKEN, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY. Every branch can read repository secrets. Consider rotating the broad token, since PR branches could read it until now.
  • Check @pywire/maintainers exists and has write access, so the CODEOWNERS rule applies.
  • After merge, run Infra apply. This rolls out the Worker, always_use_https, the www route and prevent_destroy. Then spot-check:
    • curl -I http://pywire.dev/install → 301 to https://pywire.dev/install
    • curl -I https://www.pywire.dev/ → 301 to the apex
    • HSTS and CSP headers are present on responses
  • Later: walk through several tutorial steps with the console open. If there are no [Report Only] messages, move DOCS_CSP_REPORT_ONLY into the enforced header in worker/src/index.js.

The Infra plan comment on this PR will show an error until CLOUDFLARE_READONLY_TOKEN and the read-only R2 keys exist. That's expected, and it never fails the PR.

pywire/pywire's deploy-docs.yml also 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

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
@github-actions

Copy link
Copy Markdown

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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants