Address High/Medium findings from the web app security assessment
Some checks failed
CI / build (push) Has been cancelled
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:
10
.env.example
10
.env.example
@@ -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.
|
||||
|
||||
@@ -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
215
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
173
server/index.mjs
173
server/index.mjs
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user