Skip to content

Harden Express runtime and add Cloudflare Pages security headers - #27

Merged
southbayjay merged 1 commit into
mainfrom
devin/1790176392-runtime-hardening
Sep 23, 2026
Merged

southbayjay merged 1 commit into
mainfrom
devin/1790176392-runtime-hardening

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Runtime-hardening half of the security review (findings H2, M1, L1, L3, L4, L6, L7). No behaviour change for valid requests.

Express (server/src)

  • app.set('trust proxy', TRUST_PROXY) — new TRUST_PROXY env (hop count / bool / address list, same forms Express accepts), defaulting to 1 in production and false otherwise. Without it, behind any TLS-terminating proxy express-rate-limit logs ERR_ERL_UNEXPECTED_X_FORWARDED_FOR on every request and keys all clients on the proxy IP (one shared bucket).
  • NODE_ENV default flipped 'development' → 'production' (IS_PRODUCTION exported from config). An unset env no longer serves stack traces in error bodies or skips SPA static serving; npm run dev still sets development explicitly.
  • helmet() with a CSP matching the Vite/Tailwind bundle (script-src 'self', style-src 'self' 'unsafe-inline' because PrintStyles.tsx renders a <style> element, connect-src 'self', frame-ancestors 'none'). Also removes X-Powered-By.
  • errorHandler maps body-parser error types → client status codes instead of a generic 500: entity.parse.failed→400, entity.too.large→413, *.unsupported→415, etc.
  • CORS now runs before the rate limiters so OPTIONS preflights don't consume quota.
  • Dropped: unused express.urlencoded, duplicate unhandledRejection handler, dead skip on the worksheet limiter.

Cloudflare Pages

  • New server/client/public/_headers (copied into dist/ by Vite): same CSP plus HSTS, nosniff, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy, COOP/CORP, and immutable caching for /assets/*. server/.gitignore had a boilerplate public (Gatsby) rule that silently ignored the Vite public dir — un-ignored client/public.

Verified locally (NODE_ENV unset, prod build): security headers present, X-Forwarded-For request no longer logs the rate-limit ValidationError, {bad → 400 JSON, 1.2 KB body → 413, preflight returns no RateLimit-* headers, upstream OpenAI failure still masked, dist/index.html has no inline scripts so the CSP is safe.

Link to Devin session: https://app.devin.ai/sessions/9385fb134d5f4dacb30bc28bd6bede11
Open in Devin Desktop: https://app.devin.ai/desktop/session/9385fb134d5f4dacb30bc28bd6bede11?variant=devin
Requested by: @southbayjay

- Set trust proxy (TRUST_PROXY env, defaults to 1 in production) so
  express-rate-limit keys on the real client IP behind a proxy
- Add helmet with a CSP matching the Vite/Tailwind bundle
- Default NODE_ENV to production so an unset env never leaks stacks or
  skips SPA serving
- Map body-parser errors to 400/413/415 instead of generic 500
- Run CORS before rate limiting so preflights are not counted
- Drop unused urlencoded parser, duplicate unhandledRejection handler,
  and dead health-check skip
- Add Cloudflare Pages _headers (CSP, HSTS, nosniff, frame deny, etc.)

Co-Authored-By: Jason Maycock <jason@southbaycreations.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Tested locally against production Express with NODE_ENV unset, using a clearly labeled OpenAI mock.

Production worksheet generation and print — passed

Selected 3rd Grade + Science, generated the complete worksheet, and opened print preview. No CSP violations; injected print styles were active and generation controls were hidden in print.

Generated worksheet Print preview
Production worksheet Print preview
Runtime boundaries and development regression — passed
  • Security headers present on / and /api/health; X-Powered-By absent.
  • Malformed JSON → 400; oversized fixed-length/chunked bodies → 413; unsupported charset/encoding → 415; invalid grade → 400.
  • Default (unset NODE_ENV) serves the SPA and omits stacks from errors.
  • X-Forwarded-For handled without the express-rate-limit ValidationError; RateLimit-* headers still present.
  • Preflight returns 200 with CORS headers, no rate-limit headers, and consumes no quota.
  • Built dist/_headers matches source.
  • Root npm run dev (3001 + 5173) and browser cross-origin generation work.

Not tested: real-model generation, deployed Cloudflare header enforcement, custom TRUST_PROXY values.

@southbayjay
southbayjay merged commit d65ae09 into main Sep 23, 2026
1 check passed
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.

1 participant