Skip to content

deploy: static-handler CSP/HSTS headers can silently drift out of .app.prod.yaml (and validator skips them + runtime) #815

Description

@bpowers

Problem

The committed reference app.yaml defines Content-Security-Policy and Strict-Transport-Security http_headers on the static-file handlers for the SPA shell (/$ and /new$). The production config .app.prod.yaml is gitignored and operator-maintained, and the staged deploy (scripts/deploy-web-staged.sh -> scripts/build-deploy-staging.mjs) deploys from it directly. If .app.prod.yaml omits those http_headers, the SPA shell and static assets ship to production with no CSP and no HSTS, and nothing catches the drift.

Express-layer Helmet (src/server/app.ts ~179-189) sets CSP and HSTS, but only for dynamic routes that flow through Express. GAE static_files handlers are served by the App Engine frontend and bypass Express entirely, so the static SPA shell is not covered by Helmet -- the per-handler http_headers in the deployed app.yaml are the only thing that protects it.

Because .app.prod.yaml is off-repo, this is primarily an operator/runbook item: the per-handler CSP/HSTS http_headers must be kept in sync from app.yaml into .app.prod.yaml. The robustness fix is to have scripts/validate-app-prod-config.mjs assert their presence so the drift is caught at deploy time rather than discovered in production.

Why it matters

  • Security: Without CSP, the SPA shell loses defense-in-depth against XSS/injection. Without HSTS, the browser will not pin HTTPS, weakening transport security. These protections were deliberately configured in app.yaml but can be invisibly absent in prod.
  • Silent failure mode: There is no test or check that flags missing static-handler headers; the gap surfaces only via an external header scan.
  • Not fixed by PR Pre-deploy hardening: fix silent data-loss + undo regression, pin pnpm, add canary deploy #810.

Components affected

  • app.yaml (reference: CSP/HSTS on /$ and /new$ static handlers)
  • .app.prod.yaml (off-repo, operator-maintained, used by the staged deploy)
  • scripts/validate-app-prod-config.mjs (validates GOOGLE_NODE_RUN_SCRIPTS, NODE_ENV, and the seshcookie key -- but not http_headers)
  • scripts/deploy-web-staged.sh (validates then deploys .app.prod.yaml as app.yaml)
  • src/server/app.ts ~179-189 (Helmet CSP/HSTS -- dynamic routes only, does not cover static handlers)

Possible approaches

  1. Sync the static-handler http_headers (CSP + HSTS) from app.yaml into the operator-maintained .app.prod.yaml, and document it in docs/dev/deploy.md.
  2. Extend scripts/validate-app-prod-config.mjs to assert that the SPA-shell static handlers (/$, /new$) carry both Content-Security-Policy and Strict-Transport-Security http_headers, so a stripped prod config fails validation before gcloud app deploy.

Companion gap: validator does not check the runtime field

While verifying the above, note that scripts/validate-app-prod-config.mjs also does not validate the runtime field. The reference app.yaml pins runtime: nodejs24, but a .app.prod.yaml pinned to an EOL runtime (e.g. an older nodejsNN) would pass validation today. The same validator pass that checks static-handler headers should assert a current/expected runtime so an EOL-runtime prod config is rejected at deploy time. (File separately if preferred, but it is the same validator and the same config-drift surface.)

How discovered

Identified during a pre-deploy audit of the Simlin GAE config; confirmed against the current app.yaml, scripts/validate-app-prod-config.mjs, scripts/deploy-web-staged.sh, and src/server/app.ts. Not addressed by PR #810.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions