Address High/Medium findings from the web app security assessment
Some checks failed
CI / build (push) Has been cancelled

Fixes the two High-severity findings from Isaac Hague's 19/08/2026 review
(F-01, F-02) plus F-03 through F-09:

- F-01: content writes are now sanitised server-side (sanitize-html) as the
  real security boundary — the browser-side sanitiser is UX, not enforcement,
  and a direct API write bypassed it entirely. Also closes the javascript:
  href gap in sanitiseInline().
- F-02: refusing the factory admin password no longer depends on NODE_ENV;
  it's the unconditional default now, with an explicit ALLOW_DEV_PASSWORD=1
  opt-in for local dev.
- F-03: adds CSP (report-only — the legacy inline onclick="" handlers would
  break under enforcement) and HSTS, in both server/index.mjs and vercel.json.
- F-04/F-05: rate-limits /api/leads and rotates leads.jsonl past 5MB; CSV
  export neutralises leading =+-@ so exports can't carry live formulas.
- F-06: sessions drop from 12h to 4h and are tied to a per-boot random epoch,
  so a restart now actually revokes outstanding tokens.
- F-07/F-08/F-09: generic messages on 5xx, fixed-length password comparison
  (no more length disclosure via the short-circuit), periodic throttle-map
  cleanup.

F-10 (dependency advisories): applied the two non-breaking patches (nanoid,
postcss); the vite/react-router-dom major bumps are left for a separate pass,
per the report's own recommendation. F-11 (PDF Content-Disposition) and a
CAPTCHA/honeypot on the enquiry form are deliberately left open — both are
product/UX calls, not pure security fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 18:08:24 +05:00
parent 5570eb0a9d
commit 28d0addfc0
8 changed files with 366 additions and 60 deletions

View File

@@ -3,11 +3,15 @@
# your host (systemd, Docker, IIS, etc.). A .env file is NOT loaded automatically
# by the server; it's documentation of what to set, not a config loader.
# Password for /admin (BlackDice Studio). REQUIRED before deploying —
# without it the server falls back to the development password "blackdice"
# and prints a warning on startup.
# Password for /admin (BlackDice Studio). REQUIRED — the server refuses to
# start without it (or with the factory password "blackdice"). For local
# development only, set ALLOW_DEV_PASSWORD=1 below instead of setting this.
ADMIN_PASSWORD=
# Local development only: accept the factory password "blackdice" instead of
# requiring ADMIN_PASSWORD. Never set this in a real deployment.
ALLOW_DEV_PASSWORD=
# Signs admin session tokens. Optional — defaults to a hash derived from
# ADMIN_PASSWORD. Set it separately if you want to be able to invalidate all
# sessions without changing the password.

View File

@@ -13,15 +13,18 @@ ADMIN_PASSWORD='…' npm start # serves dist/ + the API on port 8787
| Variable | Default | Purpose |
| --- | --- | --- |
| `ADMIN_PASSWORD` | `blackdice` | Password for `/admin`. **Set this** — the server logs a warning while it is unset. |
| `ADMIN_SECRET` | derived from the password | Signs session tokens. Set it to invalidate all sessions independently of the password. |
| `ADMIN_PASSWORD` | | Password for `/admin`. **Required** — the server refuses to start without it (or with the factory password `blackdice`). |
| `ALLOW_DEV_PASSWORD` | unset | Local development only: accept the factory password `blackdice` instead of requiring `ADMIN_PASSWORD`. Never set in a real deployment. |
| `ADMIN_SECRET` | derived from the password | Signs session tokens. Set it to invalidate all sessions independently of the password (a server restart also invalidates every session, regardless of this setting). |
| `PORT` | `8787` | Listen port. |
| `CONTENT_DIR` | `./content` | Published content, uploads, snapshots, enquiries. Put this on persistent storage. |
| `DIST_DIR` | `./dist` | The built site. |
| `SITE_URL` | `https://www.blackdice.ai` | Canonical origin used in `sitemap.xml`. |
Sessions last 12 hours. Failed logins are throttled at 10 per IP per 15 minutes.
Uploads are capped at 32MB and limited to images, MP4/WebM and PDF.
Sessions last 4 hours. Failed logins are throttled at 10 per IP per 15 minutes;
enquiry submissions at 8 per IP per 10 minutes. Uploads are capped at 32MB and
limited to images, MP4/WebM and PDF. `leads.jsonl` rolls over to a `.bak` file
once it passes 5MB rather than growing without bound.
## What lives where

215
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"framer-motion": "^11.18.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
"react-router-dom": "^6.26.2",
"sanitize-html": "^2.17.7"
},
"devDependencies": {
"@types/react": "^18.3.5",
@@ -745,9 +746,9 @@
}
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
"version": "1.23.4",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -1332,6 +1333,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1350,6 +1357,82 @@
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.381",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
@@ -1357,6 +1440,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1406,6 +1501,18 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
@@ -1458,6 +1565,37 @@
"node": ">=6.9.0"
}
},
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1490,6 +1628,15 @@
"node": ">=6"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1535,10 +1682,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1563,18 +1709,22 @@
"node": ">=18"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@@ -1591,7 +1741,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1635,12 +1785,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.3"
"@remix-run/router": "1.23.4"
},
"engines": {
"node": ">=14.0.0"
@@ -1650,13 +1800,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.3",
"react-router": "6.30.4"
"@remix-run/router": "1.23.4",
"react-router": "6.30.6"
},
"engines": {
"node": ">=14.0.0"
@@ -1711,6 +1861,24 @@
"fsevents": "~2.3.2"
}
},
"node_modules/sanitize-html": {
"version": "2.17.7",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1734,7 +1902,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"

View File

@@ -17,7 +17,8 @@
"framer-motion": "^11.18.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
"react-router-dom": "^6.26.2",
"sanitize-html": "^2.17.7"
},
"devDependencies": {
"@types/react": "^18.3.5",

View File

@@ -2,22 +2,27 @@
* BlackDice site + CMS server.
*
* Serves the built site from dist/, the published content document from
* CONTENT_DIR, and the small API that /admin writes through. Dependency-free
* (node: built-ins only) so it runs anywhere Node runs — VPS, container, or
* behind IIS/nginx as a reverse proxy.
* CONTENT_DIR, and the small API that /admin writes through. Otherwise
* dependency-free (node: built-ins only) so it runs anywhere Node runs — VPS,
* container, or behind IIS/nginx as a reverse proxy. The one exception is
* sanitize-html: content writes are the actual security boundary (the
* browser-side sanitiser in src/cms/sanitise.ts is UX, not enforcement — a
* direct API call bypasses it entirely), and a hand-rolled regex HTML parser
* is the wrong tool for that job, so a maintained one is used instead.
*
* node server/index.mjs
*
* Environment — read from the real process environment in production; for
* local dev, .env.local (falling back to .env) is loaded automatically below.
* PORT listen port (default 8787)
* ADMIN_PASSWORD password for /admin (required in production)
* ADMIN_SECRET token signing secret (default: derived from password)
* CONTENT_DIR where published content is kept (default ./content)
* DIST_DIR built site to serve (default ./dist)
* SITE_URL canonical origin for sitemap.xml (default https://www.blackdice.ai)
* TRUST_PROXY trust X-Forwarded-For's first hop for login rate limiting
* (only set this if a reverse proxy you control strips/sets it)
* PORT listen port (default 8787)
* ADMIN_PASSWORD password for /admin (required — see below)
* ALLOW_DEV_PASSWORD set to 1 to accept "blackdice" (local development only)
* ADMIN_SECRET token signing secret (default: derived from password)
* CONTENT_DIR where published content is kept (default ./content)
* DIST_DIR built site to serve (default ./dist)
* SITE_URL canonical origin for sitemap.xml (default https://www.blackdice.ai)
* TRUST_PROXY trust X-Forwarded-For's first hop for login rate limiting
* (only set this if a reverse proxy you control strips/sets it)
*/
import http from 'node:http'
import fs from 'node:fs'
@@ -25,6 +30,7 @@ import fsp from 'node:fs/promises'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import sanitizeHtml from 'sanitize-html'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -52,13 +58,23 @@ const TRUST_PROXY = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY =
const DEV_PASSWORD = 'blackdice'
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || DEV_PASSWORD
if (process.env.NODE_ENV === 'production' && ADMIN_PASSWORD === DEV_PASSWORD) {
console.error('✗ ADMIN_PASSWORD is unset (or "blackdice") with NODE_ENV=production. Refusing to start.')
const ALLOW_DEV_PASSWORD = process.env.ALLOW_DEV_PASSWORD === '1' || process.env.ALLOW_DEV_PASSWORD === 'true'
// Safe-by-default: refusing to start on the factory password used to depend on
// NODE_ENV=production, which meant the unsafe state was whatever you get from
// forgetting to set an environment variable. Now the safe state is the default;
// local development opts in explicitly.
if (ADMIN_PASSWORD === DEV_PASSWORD && !ALLOW_DEV_PASSWORD) {
console.error('✗ ADMIN_PASSWORD is unset (or "blackdice"). Refusing to start.')
console.error(' Set a real ADMIN_PASSWORD — see .env.example / docs/DEPLOYMENT.md.')
console.error(' For local development only, set ALLOW_DEV_PASSWORD=1 to accept "blackdice".')
process.exit(1)
}
const SECRET = process.env.ADMIN_SECRET || crypto.createHash('sha256').update('bd:' + ADMIN_PASSWORD).digest('hex')
const SESSION_MS = 12 * 60 * 60 * 1000
// Mixed into session signing so every server restart invalidates outstanding
// sessions, independent of ADMIN_PASSWORD/ADMIN_SECRET (which are otherwise
// stable across restarts and can't be rotated without changing the password).
const SESSION_EPOCH = crypto.randomBytes(16).toString('hex')
const SECRET = (process.env.ADMIN_SECRET || crypto.createHash('sha256').update('bd:' + ADMIN_PASSWORD).digest('hex')) + SESSION_EPOCH
const SESSION_MS = 4 * 60 * 60 * 1000
const MAX_BODY = 32 * 1024 * 1024 // uploads arrive as data URLs
const KEEP_VERSIONS = 60
@@ -67,6 +83,15 @@ for (const dir of [CONTENT_DIR, UPLOAD_DIR, VERSION_DIR]) fs.mkdirSync(dir, { re
// ── Auth ───────────────────────────────────────────────────────────────────────
const sign = (payload) => crypto.createHmac('sha256', SECRET).update(String(payload)).digest('base64url')
// Hashing both sides to a fixed length before the timing-safe compare means the
// two buffers are always equal-length, so there's no length short-circuit to
// leak the real password's length through response timing.
function secretsEqual(a, b) {
const ha = crypto.createHash('sha256').update(a).digest()
const hb = crypto.createHash('sha256').update(b).digest()
return crypto.timingSafeEqual(ha, hb)
}
function issueToken() {
const exp = Date.now() + SESSION_MS
return { token: `${exp}.${sign(exp)}`, expiresAt: exp }
@@ -95,11 +120,12 @@ function clientIp(req) {
}
// Crude but effective throttle: 10 failures per IP per 15 minutes.
const THROTTLE_WINDOW_MS = 15 * 60 * 1000
const failures = new Map()
function tooManyAttempts(ip) {
const rec = failures.get(ip)
if (!rec) return false
if (Date.now() - rec.first > 15 * 60 * 1000) {
if (Date.now() - rec.first > THROTTLE_WINDOW_MS) {
failures.delete(ip)
return false
}
@@ -107,20 +133,61 @@ function tooManyAttempts(ip) {
}
function noteFailure(ip) {
const rec = failures.get(ip)
if (!rec || Date.now() - rec.first > 15 * 60 * 1000) failures.set(ip, { first: Date.now(), count: 1 })
if (!rec || Date.now() - rec.first > THROTTLE_WINDOW_MS) failures.set(ip, { first: Date.now(), count: 1 })
else rec.count++
}
// Same shape, separate budget: unauthenticated public form submissions.
const LEADS_WINDOW_MS = 10 * 60 * 1000
const leadAttempts = new Map()
function tooManyLeads(ip) {
const rec = leadAttempts.get(ip)
if (!rec) return false
if (Date.now() - rec.first > LEADS_WINDOW_MS) {
leadAttempts.delete(ip)
return false
}
return rec.count >= 8
}
function noteLead(ip) {
const rec = leadAttempts.get(ip)
if (!rec || Date.now() - rec.first > LEADS_WINDOW_MS) leadAttempts.set(ip, { first: Date.now(), count: 1 })
else rec.count++
}
// Both maps are IP-keyed and only ever grow while an IP is mid-window; sweep
// stale entries periodically so a long-running process doesn't accumulate them.
setInterval(() => {
const now = Date.now()
for (const [ip, rec] of failures) if (now - rec.first > THROTTLE_WINDOW_MS) failures.delete(ip)
for (const [ip, rec] of leadAttempts) if (now - rec.first > LEADS_WINDOW_MS) leadAttempts.delete(ip)
}, THROTTLE_WINDOW_MS).unref()
// ── Helpers ────────────────────────────────────────────────────────────────────
// Applied to every response. Kept narrow (no site-wide CSP): the legacy markup
// relies on inline onclick="" handlers and third-party embeds (HubSpot), so a
// script-src lockdown would need to be introduced carefully, with the real site
// exercised in a browser first, rather than blind here.
// CSP ships in report-only mode: the legacy markup relies on inline onclick=""
// handlers, so enforcing script-src today would break navigation. Report-only
// costs nothing (it can't break the site) and surfaces exactly what a real
// policy needs to allow before anyone flips it to enforcing.
const CSP = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://*.hubspot.com https://*.hs-scripts.com https://*.hsforms.net https://*.usemessages.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:",
"media-src 'self'",
"connect-src 'self' https://*.hubspot.com https://*.hsforms.com https://*.hs-analytics.net",
"frame-src https://*.hubspot.com https://*.hsforms.com",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
].join('; ')
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'geolocation=(), camera=(), microphone=()',
'Content-Security-Policy-Report-Only': CSP,
// Harmless (and ignored) over plain HTTP; takes effect once a browser sees it over TLS.
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains',
}
const send = (res, status, body, headers = {}) => {
@@ -168,6 +235,19 @@ const readContent = async () => {
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
// Unauthenticated flooding (even rate-limited) would otherwise grow this file
// without bound. Roll it over rather than letting a single flat file take the
// whole disk; the admin's /api/leads GET only ever reads the last 200 anyway.
const LEADS_MAX_BYTES = 5 * 1024 * 1024
async function rotateLeadsIfLarge() {
try {
const { size } = await fsp.stat(LEADS_FILE)
if (size > LEADS_MAX_BYTES) await fsp.rename(LEADS_FILE, `${LEADS_FILE}.${stamp()}.bak`)
} catch {
/* no existing file yet */
}
}
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
@@ -221,6 +301,32 @@ function safeJoin(root, urlPath) {
return target === root || target.startsWith(root + path.sep) ? target : null
}
// ── Content sanitisation ─────────────────────────────────────────────────────────
// Mirrors the allow-lists in src/cms/sanitise.ts. Two profiles: block-level
// article bodies, and inline rich text (the headline-style fields written
// into data-cms="" spans in the legacy markup — see src/cms/store.tsx).
const externalLink = (tagName, attribs) =>
/^https?:\/\//i.test(attribs.href || '') ? { tagName, attribs: { ...attribs, target: '_blank', rel: 'noopener' } } : { tagName, attribs }
const BODY_SANITIZE_OPTS = {
allowedTags: [
'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'u', 'a', 'br',
'blockquote', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'hr',
],
allowedAttributes: { a: ['href', 'target', 'rel'], img: ['src', 'alt'] },
allowedSchemes: ['http', 'https', 'mailto'],
transformTags: { h1: 'h2', h5: 'h4', h6: 'h4', b: 'strong', i: 'em', a: externalLink },
}
const INLINE_SANITIZE_OPTS = {
allowedTags: ['strong', 'em', 'b', 'i', 'u', 'br', 'span', 'a', 'sup', 'sub'],
allowedAttributes: { a: ['href'] },
allowedSchemes: ['http', 'https', 'mailto'],
}
const sanitiseBodyServer = (html) => sanitizeHtml(String(html ?? ''), BODY_SANITIZE_OPTS)
const sanitiseInlineServer = (html) => sanitizeHtml(String(html ?? ''), INLINE_SANITIZE_OPTS)
// ── API ────────────────────────────────────────────────────────────────────────
const DATA_URL = /^data:([a-z0-9.+/-]+);base64,([A-Za-z0-9+/=\s]+)$/i
// SVG deliberately excluded: it can carry <script>, and uploads are served from
@@ -239,9 +345,7 @@ async function handleApi(req, res, url) {
if (route === '/api/login' && req.method === 'POST') {
if (tooManyAttempts(ip)) return send(res, 429, { error: 'Too many attempts. Try again later.' })
const { password } = await readBody(req)
const given = Buffer.from(String(password || ''))
const expected = Buffer.from(ADMIN_PASSWORD)
const ok = given.length === expected.length && crypto.timingSafeEqual(given, expected)
const ok = secretsEqual(Buffer.from(String(password || '')), Buffer.from(ADMIN_PASSWORD))
if (!ok) {
noteFailure(ip)
await new Promise((r) => setTimeout(r, 400))
@@ -257,6 +361,7 @@ async function handleApi(req, res, url) {
}
if (route === '/api/leads' && req.method === 'POST') {
if (tooManyLeads(ip)) return send(res, 429, { error: 'Too many submissions. Try again shortly.' })
const body = await readBody(req)
const lead = {
at: new Date().toISOString(),
@@ -270,6 +375,8 @@ async function handleApi(req, res, url) {
ip,
}
if (!lead.email) return send(res, 400, { error: 'email required' })
noteLead(ip)
await rotateLeadsIfLarge()
await fsp.appendFile(LEADS_FILE, JSON.stringify(lead) + '\n')
return send(res, 200, { ok: true })
}
@@ -283,6 +390,13 @@ async function handleApi(req, res, url) {
const doc = await readBody(req)
if (doc?.format !== 'blackdice-react-content') return send(res, 400, { error: 'Unexpected content format.' })
if (!Array.isArray(doc.posts)) return send(res, 400, { error: 'posts must be an array.' })
// The security boundary: src/cms/sanitise.ts only runs in the browser at edit
// time, so a request straight to this endpoint would otherwise store raw HTML
// verbatim. Re-sanitise here regardless of what the client already did.
doc.posts = doc.posts.map((p) => ({ ...p, body: sanitiseBodyServer(p?.body) }))
if (doc.content && typeof doc.content === 'object') {
doc.content = Object.fromEntries(Object.entries(doc.content).map(([id, html]) => [id, sanitiseInlineServer(html)]))
}
const previous = await readContent()
if (previous) {
await fsp.writeFile(path.join(VERSION_DIR, `site-content-${stamp()}.json`), JSON.stringify(previous))
@@ -424,8 +538,15 @@ const server = http.createServer(async (req, res) => {
return send(res, 404, 'Not found — run `npm run build` first.')
} catch (err) {
const status = err?.status || 500
if (status >= 500) console.error(err)
return send(res, status, { error: err?.message || 'Server error' })
// Below 500 the message is one we deliberately threw ourselves (readBody's
// "invalid JSON", "payload too large") — safe to show. 500s are unexpected
// and may carry internal detail (stack traces, file paths), so log them and
// return a generic message instead.
if (status >= 500) {
console.error(err)
return send(res, status, { error: 'Server error' })
}
return send(res, status, { error: err?.message || 'Error' })
}
})

View File

@@ -15,7 +15,14 @@ export default function LeadsPanel() {
const csv = () => {
const cols = ['at', 'form', 'name', 'email', 'company', 'phone', 'page', 'message']
const escape = (v: string) => `"${String(v || '').replace(/"/g, '""')}"`
// Enquiry fields arrive unauthenticated from the public form — a leading
// =, +, - or @ turns into a live formula when the export is opened in a
// spreadsheet. Prefix with a quote so it's read back as inert text.
const escape = (v: string) => {
const s = String(v || '')
const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s
return `"${safe.replace(/"/g, '""')}"`
}
const body = [cols.join(','), ...leads.map((l) => cols.map((c) => escape(l[c])).join(','))].join('\n')
const a = document.createElement('a')
a.href = URL.createObjectURL(new Blob([body], { type: 'text/csv' }))

View File

@@ -89,6 +89,7 @@ export function sanitiseInline(html: string): string {
for (const attr of Array.from(node.attributes)) {
if (!(tag === 'a' && attr.name === 'href')) node.removeAttribute(attr.name)
}
if (tag === 'a' && /^\s*javascript:/i.test(node.getAttribute('href') || '')) node.removeAttribute('href')
}
for (const child of Array.from(root.children)) walk(child)
return root.innerHTML.trim()

View File

@@ -9,7 +9,9 @@
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "geolocation=(), camera=(), microphone=()" }
{ "key": "Permissions-Policy", "value": "geolocation=(), camera=(), microphone=()" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" },
{ "key": "Content-Security-Policy-Report-Only", "value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://*.hubspot.com https://*.hs-scripts.com https://*.hsforms.net https://*.usemessages.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; media-src 'self'; connect-src 'self' https://*.hubspot.com https://*.hsforms.com https://*.hs-analytics.net; frame-src https://*.hubspot.com https://*.hsforms.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" }
]
}
]