You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The committed reference app.yaml defines Content-Security-Policy and Strict-Transport-Securityhttp_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.
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
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.
Extend scripts/validate-app-prod-config.mjs to assert that the SPA-shell static handlers (/$, /new$) carry both Content-Security-Policy and Strict-Transport-Securityhttp_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.
Problem
The committed reference
app.yamldefinesContent-Security-PolicyandStrict-Transport-Securityhttp_headerson the static-file handlers for the SPA shell (/$and/new$). The production config.app.prod.yamlis 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.yamlomits thosehttp_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. GAEstatic_fileshandlers are served by the App Engine frontend and bypass Express entirely, so the static SPA shell is not covered by Helmet -- the per-handlerhttp_headersin the deployedapp.yamlare the only thing that protects it.Because
.app.prod.yamlis off-repo, this is primarily an operator/runbook item: the per-handler CSP/HSTShttp_headersmust be kept in sync fromapp.yamlinto.app.prod.yaml. The robustness fix is to havescripts/validate-app-prod-config.mjsassert their presence so the drift is caught at deploy time rather than discovered in production.Why it matters
app.yamlbut can be invisibly absent in prod.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(validatesGOOGLE_NODE_RUN_SCRIPTS,NODE_ENV, and the seshcookie key -- but nothttp_headers)scripts/deploy-web-staged.sh(validates then deploys.app.prod.yamlasapp.yaml)src/server/app.ts~179-189 (Helmet CSP/HSTS -- dynamic routes only, does not cover static handlers)Possible approaches
http_headers(CSP + HSTS) fromapp.yamlinto the operator-maintained.app.prod.yaml, and document it indocs/dev/deploy.md.scripts/validate-app-prod-config.mjsto assert that the SPA-shell static handlers (/$,/new$) carry bothContent-Security-PolicyandStrict-Transport-Securityhttp_headers, so a stripped prod config fails validation beforegcloud app deploy.Companion gap: validator does not check the
runtimefieldWhile verifying the above, note that
scripts/validate-app-prod-config.mjsalso does not validate theruntimefield. The referenceapp.yamlpinsruntime: nodejs24, but a.app.prod.yamlpinned to an EOL runtime (e.g. an oldernodejsNN) would pass validation today. The same validator pass that checks static-handler headers should assert a current/expectedruntimeso 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, andsrc/server/app.ts. Not addressed by PR #810.