fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash

Address SonarQube security hotspots on the website:

- server/src/app.js: replace `contentSecurityPolicy: false` with a helmet CSP
  tuned for the built React SPA (script-src 'self'; style-src adds 'unsafe-inline'
  for React inline styles + the Google Fonts stylesheet; font-src gstatic; img-src
  allows data:/https: for uploads, embedded body images and BRAND_* assets;
  connect-src 'self' for REST+SSE). upgrade-insecure-requests is intentionally
  omitted (TLS terminates at the proxy; keeps local `npm start` over http working).
  The /api/docs Swagger UI route gets a scoped looser policy (inline script/style)
  since swagger-ui-express injects an inline bootstrap.
- client/vite.config.js: disable the inline module-preload polyfill so code-split
  builds keep `script-src 'self'` valid (RichTextEditor is a separate chunk).
- bot/src/app.js, server/src/internalApp.js: disable x-powered-by on the two
  internal-only listeners (the public app already strips it via helmet).
- shardEvents dedupe key: SHA-1 -> SHA-256 truncated to 40 hex chars (fits the
  existing CHAR(40) column, no migration; it is a content fingerprint, not a
  security value). schema.sql comment updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-20 23:02:11 -05:00
parent 82bf2c972c
commit e1461d9161
6 changed files with 65 additions and 9 deletions

View File

@@ -18,10 +18,17 @@ function stableStringify(value) {
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
}
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
// kind, same timestamp, same body) collapse to one row.
// dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars.
// This is a content fingerprint for idempotent INSERT IGNORE, not a security value,
// but we use SHA-256 rather than SHA-1 anyway; the truncation keeps it inside the
// CHAR(40) column (160 bits is ample collision resistance for dedupe). Two identical
// events (same kind, same timestamp, same body) collapse to one row.
function dedupeKey(kind, t, payload) {
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
return crypto
.createHash('sha256')
.update(`${kind}|${t}|${stableStringify(payload)}`)
.digest('hex')
.slice(0, 40)
}
// Append one event. Returns true if a new row was inserted (false = deduped).