Skip to content

Fixing security vulnerabilities, adding a docker compose file, and fixing app launch as it did not launch before due to package lock not lining up with what the application was requiring. - #2

Open
SluberskiHomeLab wants to merge 10 commits into
kszongic:masterfrom
SluberskiHomeLab:master

Conversation

@SluberskiHomeLab

Copy link
Copy Markdown

Summary

Full security review of FormBase, and remediation of 19 of 21 findings — including a critical authentication bypass that allowed full account takeover of any user.

Every finding was confirmed by exploiting it against a running instance before being fixed, and each has a regression test that fails on the vulnerable code and passes after.

Result
Security suite (baseline, vulnerable code) 6 passed / 33 failed
Security suite (host) 40 passed / 0 failed
Security suite (Docker) 40 passed / 0 failed
Functional smoke suite 21 passed / 0 failed (host + Docker)
npm audit 0 vulnerabilities (was 1 high)

Scope note: the Docker build fix (lockfile drift, Express 5 migration, multi-stage image) is already on master. This PR is the security work that followed from reviewing that code, and contains no build changes beyond USER node and the nodemailer bump.

Findings

ID Severity Finding Status
S1 🔴 CRITICAL Hardcoded fallback JWT signing secret → account takeover Fixed
S2 🟠 HIGH SSRF via user-controlled webhook_url Fixed
S3 🟠 HIGH No rate limiting anywhere, including login Fixed
S4 🟡 MEDIUM Origin allowlist bypassable by substring match Fixed
S5 🟡 MEDIUM CSV formula injection in export Fixed
S6 🟡 MEDIUM Header injection via Content-Disposition filename Fixed
S7 🟡 MEDIUM Unescaped user input in notification email HTML Fixed
S8 🟡 MEDIUM Unescaped sinks in dashboard SPA (incl. latent stored XSS) Fixed
S9 🔵 LOW Content-Security-Policy explicitly disabled Fixed
S10 🔵 LOW CORS wide open Fixed
S11 🔵 LOW Internal error messages returned to clients Fixed
S12 🔵 LOW Unauthenticated /api/health leaked instance stats Fixed
S13 🔵 LOW Open redirect via redirect_url Fixed
S14 🔵 LOW Login timing revealed which accounts exist Fixed
S15 🔵 LOW No server-side email validation; unbounded password Fixed
S16 🔵 LOW 30-day JWTs with no revocation ⚠️ Partial
S17 ⚪ INFO Container ran as root Fixed
S18 ⚪ INFO nodemailer advisory GHSA-p6gq-j5cr-w38f Fixed
S19 ⚪ INFO apiKeyMiddleware was dead code Fixed
S20 ⚪ INFO JWT stored in localStorage ⚠️ Accepted
S21 ⚪ INFO Dashboard "Export CSV" button was broken (401) Fixed

The three that matter most

S1 — Hardcoded JWT secret (CRITICAL)

src/auth.js fell back to 'formbase-dev-secret-change-in-production', a literal published in this repository. Any instance that did not set JWT_SECRET signed tokens that anyone could forge.

Exploited: a token signed offline with that string was accepted by /api/auth/me (HTTP 200) — full account takeover given only a user id. The readme listed JWT_SECRET as optional with the default shown.

Fixed in src/lib/secret.js. No constant fallback exists. JWT_SECRET is used when ≥32 chars; otherwise a 48-byte random secret is generated once into ${DATA_DIR}/.jwt-secret (mode 0600) and reused across restarts. The old literal and other known-weak values are rejected at boot rather than silently accepted.

Verified: forged token → 401 · boot with the old literal refuses to start · boot with a 5-char secret refuses to start · token issued before docker restart still valid after · file mode confirmed -rw-------.

S2 — SSRF via webhook_url (HIGH)

Set through the authenticated form API with no validation, then fetched server-side on every submission.

Exploited: a form pointed at http://127.0.0.1:3121/internal-admin-endpoint made the server POST submission JSON to that loopback listener. On a cloud host this reaches 169.254.169.254 and anything else routable from the container.

Fixed in src/lib/safe-url.js. Scheme restricted to http/https on write; before each send the hostname is resolved and every returned address checked against loopback, RFC1918, CGNAT, link-local, ULA, multicast and reserved ranges. redirect: 'manual' so a 302 cannot bounce into private space, plus a 5s timeout. SSRF_ALLOW_PRIVATE=1 for genuine LAN webhooks.

Verified: loopback, link-local and file:// all rejected 400 — and with a private URL written directly into SQLite, bypassing the API entirely, the listener still received nothing.

S3 — No rate limiting (HIGH)

rate-limiter-flexible was a declared dependency that was never imported anywhere. The intent was there; the wiring was not.

Exploited: 25 failed logins in 1268 ms, zero 429s.

Fixed in src/lib/rate-limit.js, wiring up the dependency already being shipped in every image. Auth 10/IP/15min and 5/account/15min; submissions 30/IP/form/min; general API 300/IP/min. All budgets env-tunable via RATE_LIMIT_*. Successful auth refunds the bucket; 429s carry Retry-After.

Verified against shipped defaults: login throttled after 6 attempts with Retry-After: 900; submissions throttled after 31.

One finding only existed because of another fix

Worth calling out for reviewers, because neither change is dangerous alone.

s.ip was rendered unescaped in the dashboard. That is harmless while req.ip is the socket address — but rate limiting (S3) needs trust proxy, and enabling that makes X-Forwarded-For attacker-controlled, turning s.ip into stored XSS in the owner's dashboard, triggered by an unauthenticated submission.

They ship together: trust proxy is opt-in via TRUST_PROXY, the server stores only a net.isIP()-validated address, and every dashboard sink is escaped.

Verified in a real browser: created a form carrying <img src=x onerror=...> in all seven previously-unescaped sinks, plus a submission with payloads in the key, the value, and X-Forwarded-For. Result: 0 of 7 XSS flags fired, 0 injected <img> tags, payloads rendered as literal text.

CSP was fixed properly, not with 'unsafe-inline'

A policy permitting 'unsafe-inline' would have been theatre. Instead the 14.6 KB inline <script> moved out of index.html into public/app.js, and every inline onclick= was replaced with delegated data-nav / data-action handlers — so the policy can genuinely be script-src 'self'.

Verified in-browser that the policy is enforced, not merely present: an injected inline <script> did not execute, and an external CDN script was blocked.

Reviewing this

15 files, +1465 / −397. Suggested order:

  1. src/lib/secret.js (+57) — the critical fix. Start here.
  2. src/auth.js — secret wiring, 7d expiry, X-API-Key support, constant-time key compare.
  3. src/lib/safe-url.js (+127) — SSRF guards. The blocked-CIDR table is the security-critical part.
  4. src/routes/submit.js — SSRF call site, origin comparison, email escaping, IP validation.
  5. src/lib/rate-limit.js (+89) and src/lib/escape.js (+37) — small, self-contained.
  6. src/routes/forms.js, src/routes/users.js, src/server.js — call sites and app wiring.
  7. test/security.js (+450) — one test per finding; useful as a spec for what each fix claims.

⚠️ public/app.js (+301) and public/index.html (−266) are mostly a file move, not new logic. Reviewing them as a rename with esc() added at seven sinks is far faster than reading 300 new lines. git diff -M or a whitespace-blind diff helps.

Testing

npm ci
npm start
npm run test:security -- http://localhost:3000

The rate-limit tests (S3) need an instance running shipped default budgets, while the rest of the suite needs headroom so it isn't throttled by its own traffic. Run two instances and pass both:

RATE_LIMIT_AUTH_IP=5000 RATE_LIMIT_AUTH_ACCOUNT=5000 RATE_LIMIT_SUBMIT=5000 PORT=3030 DATA_DIR=/tmp/fb-loose npm start
PORT=3031 DATA_DIR=/tmp/fb-strict npm start
node test/security.js http://localhost:3030 http://localhost:3031

Set FORMBASE_DATA_DIR to a locally-readable data dir to additionally exercise S2d, which writes a private webhook URL straight into SQLite to prove the fetch-time guard holds independently of API validation.

⚠️ Deployment notes — behaviour changes on upgrade

  • All existing tokens are invalidated on instances that relied on the default JWT secret; users must log in again. This is the point of S1.
  • JWT_SECRET, if set, must now be ≥32 characters and must not be the old published literal, or the process refuses to start.
  • Token lifetime 30d → 7d (JWT_EXPIRES_IN to override).
  • /api/health no longer returns counts — update monitoring that parsed them; use authenticated /api/health/stats.
  • /api is same-origin only unless CORS_ORIGIN is set. /f stays cross-origin by design.
  • webhook_url pointing at private or loopback addresses is now rejected. Set SSRF_ALLOW_PRIVATE=1 if your webhook target is genuinely on the LAN.
  • redirect_url must be http or https.
  • Set TRUST_PROXY when running behind a reverse proxy, or rate limiting keys off the proxy's address. Unset is the safe default.
  • Rate limits may be tight where many users share a source IP — tune with RATE_LIMIT_AUTH_IP / RATE_LIMIT_AUTH_ACCOUNT / RATE_LIMIT_SUBMIT / RATE_LIMIT_API.
  • Bind-mounted data directories must be chowned to uid 1000 now that the container runs as non-root. Named volumes inherit ownership automatically.
  • Emails are normalised to lowercase on write; lookups use COLLATE NOCASE so accounts created before this change can still log in.

Not fixed

  • S16 — token revocation (partial). Lifetime cut 30d → 7d, but true revocation needs a token-version column checked per request. Regenerating an API key still does not invalidate outstanding JWTs.
  • S20 — JWT in localStorage (accepted). Moving to httpOnly cookies would trade XSS exfiltration for CSRF surface and a significantly larger refactor. Materially reduced by the CSP in S9.
  • User enumeration on registration remains by design — a duplicate registration still returns 409. Removing it needs an email-verification flow. It is now rate limited.
  • DNS rebinding against the SSRF guard is narrowed but not eliminated: addresses are validated after resolution, but a TOCTOU window remains between the check and the connection. Closing it fully requires pinning the socket to the validated IP.

🤖 Generated with Claude Code

`docker build -t formbase .` failed at `RUN npm ci --production`, so no image
could be produced at all.
Root cause
----------
package.json and package-lock.json disagreed on ALL TEN dependencies. The lock
file's root `packages[""]` block declared newer majors than the manifest:
    package                package.json    package-lock.json
    bcryptjs               ^2.4.3          ^3.0.3
    better-sqlite3         ^11.0.0         ^12.8.0
    cors                   ^2.8.5          ^2.8.6
    dotenv                 ^16.4.5         ^17.3.1
    express                ^4.21.0         ^5.2.1
    helmet                 ^8.0.0          ^8.1.0
    jsonwebtoken           ^9.0.2          ^9.0.3
    nodemailer             ^6.9.0          ^8.0.3
    rate-limiter-flexible  ^5.0.0          ^10.0.1
    uuid                   ^11.0.0         ^13.0.0
`npm ci` refuses to install anything when the lock root does not satisfy the
manifest — it is a hard error, not a warning. The package name sets matched
exactly; only the ranges had drifted, which is the signature of a manifest
edited without regenerating the lock (or a lock regenerated from a different
manifest) and then committed.
Two further failures were queued up behind the first one and would have
surfaced as soon as it was cleared:
  1. `npm ci` succeeding does not mean the app runs. Adopting the lock's
     Express 5 breaks startup (see below).
  2. better-sqlite3 is a native module whose install script is
     `prebuild-install || node-gyp rebuild --release`. If no prebuild matches
     the platform, the fallback compile needs python3/make/g++, which
     node:20-alpine does not have.
Resolution: adopt the lock file's newer majors and migrate the source to match,
rather than pinning the manifest back to the older set.
Changes
-------
package.json
  * Dependency ranges raised to the lock's majors.
  * `uuid` REMOVED entirely. v13 is ESM-only — its exports map has `node` and
    `default` conditions but no `require` condition — so the existing
    `const { v4: uuid } = require('uuid')` cannot resolve under this CommonJS
    codebase on Node 20/22. Replaced by the built-in `crypto.randomUUID()`,
    which emits the same RFC 4122 v4 format, so IDs in existing databases
    remain compatible and no migration is needed.
  * Added `engines: { node: ">=20" }`, matching better-sqlite3 12's own
    declared engine range (20.x || 22.x || 23.x || 24.x || 25.x).
src/server.js
  * `app.get('/dashboard*')` -> `app.get('/dashboard{/*splat}')`. Express 5
    ships path-to-regexp 8, which rejects the bare `*` wildcard and throws
    `TypeError: Missing parameter name` at require time. This would have
    crash-looped the container on every start even after the lock was fixed.
    The new pattern matches `/dashboard` and `/dashboard/<anything>`; Express
    4's `/dashboard*` also matched `/dashboardxyz`, which was incidental rather
    than intended.
  * `dotenv.config()` -> `dotenv.config({ quiet: true })`. dotenv 17 prints an
    informational banner on load that would otherwise pollute container logs.
src/routes/{forms,submit,users}.js
  * Four `uuid()` call sites migrated to `crypto.randomUUID()`. users.js
    already required `crypto`, so its `uuid` import was dropped outright rather
    than replaced.
package-lock.json
  * Regenerated from the manifest rather than hand-edited, which is the only
    way to guarantee `npm ci` is satisfied. This also corrected a stale
    `"license": "ISC"` in the lock root that contradicted the manifest's MIT.
dockerfile -> Dockerfile
  * Renamed to the conventional capitalisation. BuildKit resolved the lowercase
    name, but readme.md documented it as `Dockerfile` and case-sensitive
    tooling can miss it.
  * Rewritten as a two-stage build. The builder carries python3/make/g++ so the
    image builds whether or not a matching better-sqlite3 prebuild exists; the
    runtime stage copies only node_modules and ships no compiler. Measured at
    256MB vs 273MB for the equivalent single-stage build, because the npm cache
    never lands in the runtime layer.
  * `npm ci --production` -> `npm ci --omit=dev` (the former is deprecated).
  * Base bumped node:20-alpine -> node:22-alpine. Node 20 reached end-of-life
    in April 2026; better-sqlite3 12 supports 22.x.
  * `DATA_DIR=/app/data` set explicitly so the app's data path and the `VOLUME`
    declaration cannot drift apart, instead of relying on db.js's relative
    `path.join(__dirname, '..', 'data')` fallback.
readme.md
  * Node badge 18+ -> 20+, which the new `engines` field now enforces.
Verification
------------
A 21-check smoke test exercised health, register, login, wrong-password
rejection, form creation, form-encoded submission, honeypot spam flagging,
submission counters, paginated listing, CSV export, SPA fallback on three
distinct /dashboard/* paths, unauthenticated-access rejection, and unknown-form
handling.
  * `npm ci --omit=dev`                  exit 0  (the originally failing command)
  * host run, Node 22                    21/21 pass
  * `docker build`                       exit 0
  * container run                        21/21 pass
  * data persistence across restart      submission counts survived
  * `docker build --no-cache` from clean exit 0, 21/21 pass
An earlier assumption that better-sqlite3 would require the toolchain on musl
was tested and disproved: 12.11.1 does publish musl prebuilds, and
`prebuild-install` succeeds on node:22-alpine with no compiler present. The
multi-stage layout is retained on its own merits (smaller image, no toolchain
at runtime, resilience if a future version or architecture lacks a prebuild).
SECURITY FINDINGS
=================
Found while working through the codebase. NONE of these are fixed by this
commit — it is scoped to restoring the build. All were confirmed by running
them against a live instance unless marked "code review".
[CRITICAL] Hardcoded fallback JWT signing secret
  src/auth.js:2 — `process.env.JWT_SECRET || 'formbase-dev-secret-change-in-production'`
  Any deployment that does not set JWT_SECRET signs tokens with a secret that
  is published in the public repository. Confirmed: a token forged offline with
  that literal string was accepted by `GET /api/auth/me` (HTTP 200), granting
  full account takeover of any user whose id is known. The readme lists
  JWT_SECRET as optional with the default shown, and neither the Dockerfile nor
  the app warns or refuses to start when it is unset.
  Fix: refuse to boot when JWT_SECRET is missing, or generate and persist a
  random secret on first run.
[HIGH] Server-side request forgery via webhook_url
  src/routes/submit.js:54 — `fetch(form.webhook_url, { method: 'POST', ... })`
  webhook_url is set by the user through POST/PATCH /api/forms with no scheme,
  host, or private-range validation, and is then fetched server-side on every
  submission. Confirmed: a form configured with
  `http://127.0.0.1:<port>/internal-admin-endpoint` caused the server to POST
  submission JSON to that loopback listener. In a cloud deployment this reaches
  link-local metadata services and anything else routable from the container.
  Fix: allowlist schemes to http/https, resolve and reject private/loopback/
  link-local ranges, disable redirects, apply a timeout.
[HIGH] No rate limiting anywhere, including login
  rate-limiter-flexible is declared as a dependency but is never imported
  anywhere in src/ — the intent was clearly there and was never wired up.
  Confirmed: 25 consecutive failed logins against one account completed in
  1268ms with zero 429 responses. /f/:formId is likewise unthrottled, so the
  per-plan monthly quota is the only brake on submission flooding, and it is
  evaluated only after the row is already written.
  Fix: wire up the dependency that is already installed and paid for in every
  image build.
[MEDIUM] Origin allowlist bypassable by substring match
  src/routes/submit.js:17 — `origins.some(o => origin.includes(o))`
  The check is a substring test against the raw Origin/Referer header rather
  than a parsed-host comparison. Confirmed: a form restricted to `trusted.com`
  accepted a submission carrying `Origin: https://trusted.com.evil.example`
  (HTTP 200). Any attacker-controlled domain that merely contains the allowed
  string defeats the control.
  Fix: parse with `new URL(origin).host` and compare hosts exactly.
[MEDIUM] CSV formula injection in export
  src/routes/forms.js:86 — the escape helper quotes and doubles `"` but does
  nothing about leading `=`, `+`, `-`, or `@`.
  Confirmed: a submitted value of `=cmd|' /C calc'!A0` was written to the
  export as `"=cmd|' /C calc'!A0"`, which Excel and LibreOffice interpret as a
  formula when the downloaded file is opened. The submission endpoint is public
  and unauthenticated, so any visitor can plant a payload aimed at the form
  owner.
  Fix: prefix cells beginning with those characters with an apostrophe.
[MEDIUM] Header injection via Content-Disposition filename
  src/routes/forms.js:89 — `attachment; filename="${form.name}-submissions.csv"`
  form.name is user-controlled and interpolated unescaped into the header.
  Confirmed: a form named `evil"; drop=1; x="` produced
  `attachment; filename="evil"; drop=1; x="-submissions.csv"`, breaking out of
  the quoted string and injecting extra header parameters.
  Fix: strip quotes and control characters, or use RFC 5987 filename* encoding.
[MEDIUM] Unescaped user input in notification email HTML  (code review)
  src/routes/submit.js:71 — `` `<b>${k}:</b> ${v}` `` builds the email body by
  string interpolation with no HTML escaping, from a public unauthenticated
  endpoint. Both keys and values are attacker-controlled. Not exercised at
  runtime because it requires SMTP_HOST to be configured.
  Fix: HTML-escape both, or send text/plain.
[LOW] Content-Security-Policy explicitly disabled
  src/server.js:11 — `helmet({ contentSecurityPolicy: false })` opts out of the
  single most useful header helmet provides, removing the defence-in-depth that
  would blunt the injection issues above. The dashboard is one self-contained
  HTML file with no external resources, so a strict policy should be
  straightforward.
[LOW] CORS wide open
  src/server.js:12 — bare `cors()` reflects any origin. Lower impact than it
  looks because auth travels in an Authorization header rather than cookies, so
  there is no ambient-credential CSRF, but it should still be constrained.
[LOW] Internal error messages returned to clients
  Every route's catch block does `res.status(500).json({ error: e.message })`,
  forwarding raw exception text — including SQLite errors and schema details —
  to the caller regardless of NODE_ENV.
[LOW] Unauthenticated /api/health leaks instance statistics
  src/server.js:26 returns exact user, form, and submission counts with no auth.
  Confirmed: `{"status":"ok","users":1,"forms":2,"submissions":2,...}`.
[LOW] User enumeration on registration
  src/routes/users.js — duplicate registration returns 409 "Email already
  registered", confirming which addresses hold accounts.
[INFO] nodemailer advisory unresolved
  The approved ^8.0.3 range remains exposed to GHSA-p6gq-j5cr-w38f (high;
  affects <=9.0.0), the `raw` option bypassing disableFileAccess/
  disableUrlAccess. Actual exposure here is nil — src/routes/submit.js passes
  only to/subject/html and never `raw` — but `npm audit` will continue to flag
  it. Clearing it requires nodemailer 9, a breaking change beyond the versions
  agreed for this commit.
[INFO] Container runs as root
  The runtime stage does not drop privileges. `USER node` was deliberately
  omitted here to avoid coupling a build fix to a change in volume ownership
  semantics, which breaks bind mounts.
[INFO] Dead code: apiKeyMiddleware
  src/auth.js:20 is exported but never mounted on any route, so the documented
  X-API-Key access path does not actually exist. Not a vulnerability, but the
  readme advertises a feature that is unreachable.
`docker build -t formbase .` failed at `RUN npm ci --production`, so no image
could be produced at all.
Root cause
----------
package.json and package-lock.json disagreed on ALL TEN dependencies. The lock
file's root `packages[""]` block declared newer majors than the manifest:
    package                package.json    package-lock.json
    bcryptjs               ^2.4.3          ^3.0.3
    better-sqlite3         ^11.0.0         ^12.8.0
    cors                   ^2.8.5          ^2.8.6
    dotenv                 ^16.4.5         ^17.3.1
    express                ^4.21.0         ^5.2.1
    helmet                 ^8.0.0          ^8.1.0
    jsonwebtoken           ^9.0.2          ^9.0.3
    nodemailer             ^6.9.0          ^8.0.3
    rate-limiter-flexible  ^5.0.0          ^10.0.1
    uuid                   ^11.0.0         ^13.0.0
`npm ci` refuses to install anything when the lock root does not satisfy the
manifest — it is a hard error, not a warning. The package name sets matched
exactly; only the ranges had drifted, which is the signature of a manifest
edited without regenerating the lock (or a lock regenerated from a different
manifest) and then committed.
Two further failures were queued up behind the first one and would have
surfaced as soon as it was cleared:
  1. `npm ci` succeeding does not mean the app runs. Adopting the lock's
     Express 5 breaks startup (see below).
  2. better-sqlite3 is a native module whose install script is
     `prebuild-install || node-gyp rebuild --release`. If no prebuild matches
     the platform, the fallback compile needs python3/make/g++, which
     node:20-alpine does not have.
Resolution: adopt the lock file's newer majors and migrate the source to match,
rather than pinning the manifest back to the older set.
Changes
-------
package.json
  * Dependency ranges raised to the lock's majors.
  * `uuid` REMOVED entirely. v13 is ESM-only — its exports map has `node` and
    `default` conditions but no `require` condition — so the existing
    `const { v4: uuid } = require('uuid')` cannot resolve under this CommonJS
    codebase on Node 20/22. Replaced by the built-in `crypto.randomUUID()`,
    which emits the same RFC 4122 v4 format, so IDs in existing databases
    remain compatible and no migration is needed.
  * Added `engines: { node: ">=20" }`, matching better-sqlite3 12's own
    declared engine range (20.x || 22.x || 23.x || 24.x || 25.x).
src/server.js
  * `app.get('/dashboard*')` -> `app.get('/dashboard{/*splat}')`. Express 5
    ships path-to-regexp 8, which rejects the bare `*` wildcard and throws
    `TypeError: Missing parameter name` at require time. This would have
    crash-looped the container on every start even after the lock was fixed.
    The new pattern matches `/dashboard` and `/dashboard/<anything>`; Express
    4's `/dashboard*` also matched `/dashboardxyz`, which was incidental rather
    than intended.
  * `dotenv.config()` -> `dotenv.config({ quiet: true })`. dotenv 17 prints an
    informational banner on load that would otherwise pollute container logs.
src/routes/{forms,submit,users}.js
  * Four `uuid()` call sites migrated to `crypto.randomUUID()`. users.js
    already required `crypto`, so its `uuid` import was dropped outright rather
    than replaced.
package-lock.json
  * Regenerated from the manifest rather than hand-edited, which is the only
    way to guarantee `npm ci` is satisfied. This also corrected a stale
    `"license": "ISC"` in the lock root that contradicted the manifest's MIT.
dockerfile -> Dockerfile
  * Renamed to the conventional capitalisation. BuildKit resolved the lowercase
    name, but readme.md documented it as `Dockerfile` and case-sensitive
    tooling can miss it.
  * Rewritten as a two-stage build. The builder carries python3/make/g++ so the
    image builds whether or not a matching better-sqlite3 prebuild exists; the
    runtime stage copies only node_modules and ships no compiler. Measured at
    256MB vs 273MB for the equivalent single-stage build, because the npm cache
    never lands in the runtime layer.
  * `npm ci --production` -> `npm ci --omit=dev` (the former is deprecated).
  * Base bumped node:20-alpine -> node:22-alpine. Node 20 reached end-of-life
    in April 2026; better-sqlite3 12 supports 22.x.
  * `DATA_DIR=/app/data` set explicitly so the app's data path and the `VOLUME`
    declaration cannot drift apart, instead of relying on db.js's relative
    `path.join(__dirname, '..', 'data')` fallback.
readme.md
  * Node badge 18+ -> 20+, which the new `engines` field now enforces.
Verification
------------
A 21-check smoke test exercised health, register, login, wrong-password
rejection, form creation, form-encoded submission, honeypot spam flagging,
submission counters, paginated listing, CSV export, SPA fallback on three
distinct /dashboard/* paths, unauthenticated-access rejection, and unknown-form
handling.
  * `npm ci --omit=dev`                  exit 0  (the originally failing command)
  * host run, Node 22                    21/21 pass
  * `docker build`                       exit 0
  * container run                        21/21 pass
  * data persistence across restart      submission counts survived
  * `docker build --no-cache` from clean exit 0, 21/21 pass
An earlier assumption that better-sqlite3 would require the toolchain on musl
was tested and disproved: 12.11.1 does publish musl prebuilds, and
`prebuild-install` succeeds on node:22-alpine with no compiler present. The
multi-stage layout is retained on its own merits (smaller image, no toolchain
at runtime, resilience if a future version or architecture lacks a prebuild).
SECURITY FINDINGS
=================
Found while working through the codebase. NONE of these are fixed by this
commit — it is scoped to restoring the build. All were confirmed by running
them against a live instance unless marked "code review".
[CRITICAL] Hardcoded fallback JWT signing secret
  src/auth.js:2 — `process.env.JWT_SECRET || 'formbase-dev-secret-change-in-production'`
  Any deployment that does not set JWT_SECRET signs tokens with a secret that
  is published in the public repository. Confirmed: a token forged offline with
  that literal string was accepted by `GET /api/auth/me` (HTTP 200), granting
  full account takeover of any user whose id is known. The readme lists
  JWT_SECRET as optional with the default shown, and neither the Dockerfile nor
  the app warns or refuses to start when it is unset.
  Fix: refuse to boot when JWT_SECRET is missing, or generate and persist a
  random secret on first run.
[HIGH] Server-side request forgery via webhook_url
  src/routes/submit.js:54 — `fetch(form.webhook_url, { method: 'POST', ... })`
  webhook_url is set by the user through POST/PATCH /api/forms with no scheme,
  host, or private-range validation, and is then fetched server-side on every
  submission. Confirmed: a form configured with
  `http://127.0.0.1:<port>/internal-admin-endpoint` caused the server to POST
  submission JSON to that loopback listener. In a cloud deployment this reaches
  link-local metadata services and anything else routable from the container.
  Fix: allowlist schemes to http/https, resolve and reject private/loopback/
  link-local ranges, disable redirects, apply a timeout.
[HIGH] No rate limiting anywhere, including login
  rate-limiter-flexible is declared as a dependency but is never imported
  anywhere in src/ — the intent was clearly there and was never wired up.
  Confirmed: 25 consecutive failed logins against one account completed in
  1268ms with zero 429 responses. /f/:formId is likewise unthrottled, so the
  per-plan monthly quota is the only brake on submission flooding, and it is
  evaluated only after the row is already written.
  Fix: wire up the dependency that is already installed and paid for in every
  image build.
[MEDIUM] Origin allowlist bypassable by substring match
  src/routes/submit.js:17 — `origins.some(o => origin.includes(o))`
  The check is a substring test against the raw Origin/Referer header rather
  than a parsed-host comparison. Confirmed: a form restricted to `trusted.com`
  accepted a submission carrying `Origin: https://trusted.com.evil.example`
  (HTTP 200). Any attacker-controlled domain that merely contains the allowed
  string defeats the control.
  Fix: parse with `new URL(origin).host` and compare hosts exactly.
[MEDIUM] CSV formula injection in export
  src/routes/forms.js:86 — the escape helper quotes and doubles `"` but does
  nothing about leading `=`, `+`, `-`, or `@`.
  Confirmed: a submitted value of `=cmd|' /C calc'!A0` was written to the
  export as `"=cmd|' /C calc'!A0"`, which Excel and LibreOffice interpret as a
  formula when the downloaded file is opened. The submission endpoint is public
  and unauthenticated, so any visitor can plant a payload aimed at the form
  owner.
  Fix: prefix cells beginning with those characters with an apostrophe.
[MEDIUM] Header injection via Content-Disposition filename
  src/routes/forms.js:89 — `attachment; filename="${form.name}-submissions.csv"`
  form.name is user-controlled and interpolated unescaped into the header.
  Confirmed: a form named `evil"; drop=1; x="` produced
  `attachment; filename="evil"; drop=1; x="-submissions.csv"`, breaking out of
  the quoted string and injecting extra header parameters.
  Fix: strip quotes and control characters, or use RFC 5987 filename* encoding.
[MEDIUM] Unescaped user input in notification email HTML  (code review)
  src/routes/submit.js:71 — `` `<b>${k}:</b> ${v}` `` builds the email body by
  string interpolation with no HTML escaping, from a public unauthenticated
  endpoint. Both keys and values are attacker-controlled. Not exercised at
  runtime because it requires SMTP_HOST to be configured.
  Fix: HTML-escape both, or send text/plain.
[LOW] Content-Security-Policy explicitly disabled
  src/server.js:11 — `helmet({ contentSecurityPolicy: false })` opts out of the
  single most useful header helmet provides, removing the defence-in-depth that
  would blunt the injection issues above. The dashboard is one self-contained
  HTML file with no external resources, so a strict policy should be
  straightforward.
[LOW] CORS wide open
  src/server.js:12 — bare `cors()` reflects any origin. Lower impact than it
  looks because auth travels in an Authorization header rather than cookies, so
  there is no ambient-credential CSRF, but it should still be constrained.
[LOW] Internal error messages returned to clients
  Every route's catch block does `res.status(500).json({ error: e.message })`,
  forwarding raw exception text — including SQLite errors and schema details —
  to the caller regardless of NODE_ENV.
[LOW] Unauthenticated /api/health leaks instance statistics
  src/server.js:26 returns exact user, form, and submission counts with no auth.
  Confirmed: `{"status":"ok","users":1,"forms":2,"submissions":2,...}`.
[LOW] User enumeration on registration
  src/routes/users.js — duplicate registration returns 409 "Email already
  registered", confirming which addresses hold accounts.
[INFO] nodemailer advisory unresolved
  The approved ^8.0.3 range remains exposed to GHSA-p6gq-j5cr-w38f (high;
  affects <=9.0.0), the `raw` option bypassing disableFileAccess/
  disableUrlAccess. Actual exposure here is nil — src/routes/submit.js passes
  only to/subject/html and never `raw` — but `npm audit` will continue to flag
  it. Clearing it requires nodemailer 9, a breaking change beyond the versions
  agreed for this commit.
[INFO] Container runs as root
  The runtime stage does not drop privileges. `USER node` was deliberately
  omitted here to avoid coupling a build fix to a change in volume ownership
  semantics, which breaks bind mounts.
[INFO] Dead code: apiKeyMiddleware
  src/auth.js:20 is exported but never mounted on any route, so the documented
  X-API-Key access path does not actually exist. Not a vulnerability, but the
  readme advertises a feature that is unreachable.
Two pieces of work. The build could not produce an image at all; fixing that
exposed the codebase to review, which turned up a critical authentication
bypass and eighteen other issues. Both are covered below.
Every security finding was confirmed by exploiting it against a running
instance before being fixed, and each has a regression test that fails on the
vulnerable code and passes after. Suite results:
    baseline (vulnerable):   6 passed / 33 failed
    final (host):           40 passed /  0 failed
    final (Docker):         40 passed /  0 failed
    functional smoke:       21 passed /  0 failed  (host and Docker)
    npm audit:              0 vulnerabilities
===============================================================================
PART 1 - BROKEN DOCKER BUILD
===============================================================================
`docker build -t formbase .` failed at `RUN npm ci --production`.
Root cause
----------
package.json and package-lock.json disagreed on ALL TEN dependencies. The lock
root declared newer majors than the manifest:
    package                package.json    package-lock.json
    bcryptjs               ^2.4.3          ^3.0.3
    better-sqlite3         ^11.0.0         ^12.8.0
    cors                   ^2.8.5          ^2.8.6
    dotenv                 ^16.4.5         ^17.3.1
    express                ^4.21.0         ^5.2.1
    helmet                 ^8.0.0          ^8.1.0
    jsonwebtoken           ^9.0.2          ^9.0.3
    nodemailer             ^6.9.0          ^8.0.3
    rate-limiter-flexible  ^5.0.0          ^10.0.1
    uuid                   ^11.0.0         ^13.0.0
`npm ci` refuses to install anything when the lock root does not satisfy the
manifest. The package name sets matched exactly and only the ranges had
drifted -- the signature of a manifest edited without regenerating the lock,
then committed.
Resolution: adopt the lock's newer majors and migrate the source to match.
Changes
-------
* package.json: ranges raised to the lock's majors; `engines: node >=20` added
  to match better-sqlite3 12's own range.
* uuid REMOVED as a dependency. v13 is ESM-only -- its exports map has `node`
  and `default` conditions but no `require` -- so `require('uuid')` cannot
  resolve in this CommonJS codebase. All four call sites now use the built-in
  `crypto.randomUUID()`, which emits the same RFC 4122 v4 format, so existing
  database IDs stay compatible and no migration is needed.
* src/server.js: `app.get('/dashboard*')` -> `app.get('/dashboard{/*splat}')`.
  Express 5 ships path-to-regexp 8, which rejects the bare `*` and throws
  `TypeError: Missing parameter name` at require time -- the container would
  have crash-looped on every start even once the lock was fixed.
* package-lock.json regenerated from the manifest rather than hand-edited,
  which is the only way `npm ci` can be satisfied. Also corrected a stale
  `"license": "ISC"` in the lock root that contradicted the manifest's MIT.
* dockerfile -> Dockerfile (conventional casing; the readme already said so).
  Rewritten as a two-stage build: the builder carries python3/make/g++ so the
  image builds whether or not a matching better-sqlite3 prebuild exists, and
  the runtime stage ships no compiler. 256MB vs 273MB single-stage, because
  the npm cache never reaches the runtime layer. `npm ci --production` ->
  `--omit=dev` (former is deprecated). Base node:20-alpine -> node:22-alpine;
  Node 20 reached EOL in April 2026. DATA_DIR set explicitly so the app's data
  path and the VOLUME declaration cannot drift apart.
An assumption that better-sqlite3 would need the toolchain on musl was tested
and disproved -- 12.11.1 does publish musl prebuilds and prebuild-install
succeeds with no compiler present. The multi-stage layout is kept on its own
merits: smaller image, no toolchain at runtime, and resilience if a future
version or architecture lacks a prebuild.
===============================================================================
PART 2 - SECURITY REMEDIATION
===============================================================================
S1 [CRITICAL] Hardcoded fallback JWT signing secret
  src/auth.js fell back to 'formbase-dev-secret-change-in-production', a
  literal published in this repository. Any instance without JWT_SECRET set
  signed tokens anyone could forge.
  Exploited: a token signed offline with that string was accepted by
  /api/auth/me (HTTP 200) -- full account takeover given only a user id.
  Fixed in src/lib/secret.js. No constant fallback exists. JWT_SECRET is used
  when >= 32 chars; otherwise a 48-byte random secret is generated once into
  ${DATA_DIR}/.jwt-secret (mode 0600) and reused. The old literal and other
  known-weak values are rejected at boot rather than silently accepted.
  Verified: forged token -> 401; boot with the old literal refuses to start;
  boot with a 5-char secret refuses to start; a token issued before
  `docker restart` still works after it; file mode confirmed -rw-------.
S2 [HIGH] Server-side request forgery via webhook_url
  User-set through the form API with no validation, then fetched server-side
  on every submission.
  Exploited: a form pointed at http://127.0.0.1:3121/internal-admin-endpoint
  made the server POST submission JSON to that loopback listener. In a cloud
  deployment this reaches 169.254.169.254 and anything else routable.
  Fixed in src/lib/safe-url.js. Scheme restricted to http/https on write; the
  hostname is resolved before each send and every returned address checked
  against loopback, RFC1918, CGNAT, link-local, ULA, multicast and reserved
  ranges. redirect:'manual' so a 302 cannot bounce into private space; 5s
  timeout. SSRF_ALLOW_PRIVATE=1 for genuine LAN webhooks.
  Verified: loopback, link-local and file:// all rejected 400; and with a
  private URL written DIRECTLY INTO SQLITE, bypassing the API entirely, the
  listener still received nothing.
S3 [HIGH] No rate limiting anywhere
  rate-limiter-flexible was a declared dependency that was never imported.
  Exploited: 25 failed logins in 1268ms, zero 429s. /f/:formId equally
  unthrottled, and the monthly quota was checked only AFTER the row was
  inserted.
  Fixed in src/lib/rate-limit.js, wiring up the dependency that was already
  installed. Auth 10/IP/15min and 5/account/15min; submissions 30/IP/form/min;
  general API 300/IP/min. All budgets env-tunable via RATE_LIMIT_* for
  shared-NAT deployments. Successful auth refunds the bucket; 429s carry
  Retry-After.
  Verified against shipped defaults: login throttled after 6 attempts with
  Retry-After: 900; submissions throttled after 31.
S4 [MEDIUM] Origin allowlist bypassable by substring match
  src/routes/submit.js used `origins.some(o => origin.includes(o))`.
  Exploited: a form restricted to trusted.com accepted
  Origin: https://trusted.com.evil.example.
  Fixed: parse with new URL() and compare host exactly; `*` and `*.example.com`
  supported; an unparseable Origin now refuses rather than falling through.
S5 [MEDIUM] CSV formula injection in export
  Exploited: a submitted value of `=cmd|' /C calc'!A0` was written verbatim and
  is executed as a formula by Excel/LibreOffice on open. The submission
  endpoint is public, so any stranger can plant a payload that detonates on the
  form owner's machine.
  Fixed: csvCell() prefixes cells starting with = + - @ TAB CR with an
  apostrophe.
S6 [MEDIUM] Header injection via Content-Disposition filename
  Exploited: a form named `evil"; drop=1; x="` produced
  `attachment; filename="evil"; drop=1; x="-submissions.csv"` -- broke out of
  the quoted string and injected extra header parameters.
  Fixed: contentDisposition() strips quotes and control characters and adds
  RFC 5987 filename*=UTF-8'' encoding.
S7 [MEDIUM] Unescaped user input in notification email HTML
  Keys and values from a public unauthenticated endpoint were interpolated
  straight into the email body.
  Fixed: escapeHtml() applied to key, value and form name.
S8 [MEDIUM] Unescaped sinks in the dashboard SPA
  The author's esc() helper covered form name and submission data but missed
  state.user.email, honeypot_field, notify_email, redirect_url, webhook_url,
  allowed_origins and s.ip.
  s.ip was the dangerous one: harmless while req.ip is the socket address, but
  it becomes attacker-controlled the moment trust proxy is enabled -- which S3
  required -- turning it into stored XSS in the owner's dashboard triggered by
  an UNAUTHENTICATED submission. Individually safe, jointly exploitable, so
  they ship together.
  Fixed: every sink escaped; the server stores only a net.isIP()-validated
  address; trust proxy is opt-in via TRUST_PROXY so X-Forwarded-For cannot be
  spoofed by default.
  Verified in a real browser: a form carrying <img src=x onerror=...> in all
  seven sinks, plus a submission with payloads in key, value and
  X-Forwarded-For -- 0 of 7 XSS flags fired, 0 injected img tags, payloads
  rendered as literal text.
S9 [LOW] Content-Security-Policy explicitly disabled
  helmet({ contentSecurityPolicy: false }) opted out of the one header that
  would blunt S7/S8.
  Fixed properly rather than with 'unsafe-inline', which would have been
  theatre: the 14.6KB inline <script> moved to public/app.js and every inline
  onclick= replaced with delegated data-nav / data-action handlers, so the
  policy can be script-src 'self'. Added object-src 'none', base-uri 'self',
  frame-ancestors 'none', form-action 'self'.
  Verified in-browser that the policy is ENFORCED, not merely present: an
  injected inline <script> did not execute and an external CDN script was
  blocked.
S10 [LOW] CORS wide open
  Bare cors() reflected any origin. Fixed: /api restricted to CORS_ORIGIN
  (default same-origin, no ACAO emitted); /f stays cross-origin by design as a
  public submission endpoint, governed per form by S4's allowlist.
S11 [LOW] Internal error messages returned to clients
  Every catch block returned `e.message`, forwarding raw driver text
  regardless of NODE_ENV -- confirmed leaking `SqliteError: datatype mismatch`.
  Fixed: central Express error handler returns { error, ref } with full detail
  logged server-side against that ref. Malformed JSON returns a clean 400
  instead of an HTML stack trace; oversized bodies 413; unknown /api/* routes
  return JSON 404 rather than HTML.
S12 [LOW] Unauthenticated /api/health leaked instance statistics
  Returned exact user, form and submission counts to anyone.
  Fixed: public probe returns status and uptime only; counts moved to
  authenticated /api/health/stats.
## Summary

Full security review of FormBase, and remediation of **19 of 21 findings** — including a critical authentication bypass that allowed full account takeover of any user.

Every finding was **confirmed by exploiting it against a running instance** before being fixed, and each has a regression test that fails on the vulnerable code and passes after.

| | Result |
|---|---|
| Security suite (baseline, vulnerable code) | 6 passed / 33 failed |
| Security suite (host) | **40 passed / 0 failed** |
| Security suite (Docker) | **40 passed / 0 failed** |
| Functional smoke suite | **21 passed / 0 failed** (host + Docker) |
| `npm audit` | **0 vulnerabilities** (was 1 high) |

> **Scope note:** the Docker build fix (lockfile drift, Express 5 migration, multi-stage image) is already on `master`. This PR is the security work that followed from reviewing that code, and contains no build changes beyond `USER node` and the nodemailer bump.

## Findings

| ID | Severity | Finding | Status |
|----|----------|---------|--------|
| S1 | 🔴 **CRITICAL** | Hardcoded fallback JWT signing secret → account takeover | Fixed |
| S2 | 🟠 HIGH | SSRF via user-controlled `webhook_url` | Fixed |
| S3 | 🟠 HIGH | No rate limiting anywhere, including login | Fixed |
| S4 | 🟡 MEDIUM | Origin allowlist bypassable by substring match | Fixed |
| S5 | 🟡 MEDIUM | CSV formula injection in export | Fixed |
| S6 | 🟡 MEDIUM | Header injection via `Content-Disposition` filename | Fixed |
| S7 | 🟡 MEDIUM | Unescaped user input in notification email HTML | Fixed |
| S8 | 🟡 MEDIUM | Unescaped sinks in dashboard SPA (incl. latent stored XSS) | Fixed |
| S9 | 🔵 LOW | Content-Security-Policy explicitly disabled | Fixed |
| S10 | 🔵 LOW | CORS wide open | Fixed |
| S11 | 🔵 LOW | Internal error messages returned to clients | Fixed |
| S12 | 🔵 LOW | Unauthenticated `/api/health` leaked instance stats | Fixed |
| S13 | 🔵 LOW | Open redirect via `redirect_url` | Fixed |
| S14 | 🔵 LOW | Login timing revealed which accounts exist | Fixed |
| S15 | 🔵 LOW | No server-side email validation; unbounded password | Fixed |
| S16 | 🔵 LOW | 30-day JWTs with no revocation | ⚠️ Partial |
| S17 | ⚪ INFO | Container ran as root | Fixed |
| S18 | ⚪ INFO | nodemailer advisory GHSA-p6gq-j5cr-w38f | Fixed |
| S19 | ⚪ INFO | `apiKeyMiddleware` was dead code | Fixed |
| S20 | ⚪ INFO | JWT stored in `localStorage` | ⚠️ Accepted |
| S21 | ⚪ INFO | Dashboard "Export CSV" button was broken (401) | Fixed |

## The three that matter most

### S1 — Hardcoded JWT secret (CRITICAL)

`src/auth.js` fell back to `'formbase-dev-secret-change-in-production'`, a literal published in this repository. Any instance that did not set `JWT_SECRET` signed tokens that anyone could forge.

**Exploited:** a token signed offline with that string was accepted by `/api/auth/me` (HTTP 200) — full account takeover given only a user id. The readme listed `JWT_SECRET` as optional *with the default shown*.

**Fixed** in `src/lib/secret.js`. No constant fallback exists. `JWT_SECRET` is used when ≥32 chars; otherwise a 48-byte random secret is generated once into `${DATA_DIR}/.jwt-secret` (mode 0600) and reused across restarts. The old literal and other known-weak values are rejected at boot rather than silently accepted.

Verified: forged token → 401 · boot with the old literal refuses to start · boot with a 5-char secret refuses to start · token issued before `docker restart` still valid after · file mode confirmed `-rw-------`.

### S2 — SSRF via `webhook_url` (HIGH)

Set through the authenticated form API with no validation, then fetched server-side on every submission.

**Exploited:** a form pointed at `http://127.0.0.1:3121/internal-admin-endpoint` made the server POST submission JSON to that loopback listener. On a cloud host this reaches `169.254.169.254` and anything else routable from the container.

**Fixed** in `src/lib/safe-url.js`. Scheme restricted to http/https on write; before each send the hostname is resolved and **every returned address** checked against loopback, RFC1918, CGNAT, link-local, ULA, multicast and reserved ranges. `redirect: 'manual'` so a 302 cannot bounce into private space, plus a 5s timeout. `SSRF_ALLOW_PRIVATE=1` for genuine LAN webhooks.

Verified: loopback, link-local and `file://` all rejected 400 — and with a private URL written **directly into SQLite**, bypassing the API entirely, the listener still received nothing.

### S3 — No rate limiting (HIGH)

`rate-limiter-flexible` was a declared dependency that was **never imported anywhere**. The intent was there; the wiring was not.

**Exploited:** 25 failed logins in 1268 ms, zero 429s.

**Fixed** in `src/lib/rate-limit.js`, wiring up the dependency already being shipped in every image. Auth 10/IP/15min and 5/account/15min; submissions 30/IP/form/min; general API 300/IP/min. All budgets env-tunable via `RATE_LIMIT_*`. Successful auth refunds the bucket; 429s carry `Retry-After`.

Verified against shipped defaults: login throttled after 6 attempts with `Retry-After: 900`; submissions throttled after 31.

## One finding only existed because of another fix

Worth calling out for reviewers, because neither change is dangerous alone.

`s.ip` was rendered unescaped in the dashboard. That is harmless while `req.ip` is the socket address — but rate limiting (S3) needs `trust proxy`, and enabling that makes `X-Forwarded-For` attacker-controlled, turning `s.ip` into **stored XSS in the owner's dashboard, triggered by an unauthenticated submission**.

They ship together: `trust proxy` is opt-in via `TRUST_PROXY`, the server stores only a `net.isIP()`-validated address, and every dashboard sink is escaped.

**Verified in a real browser:** created a form carrying `<img src=x onerror=...>` in all seven previously-unescaped sinks, plus a submission with payloads in the key, the value, and `X-Forwarded-For`. Result: **0 of 7 XSS flags fired, 0 injected `<img>` tags**, payloads rendered as literal text.

## CSP was fixed properly, not with `'unsafe-inline'`

A policy permitting `'unsafe-inline'` would have been theatre. Instead the 14.6 KB inline `<script>` moved out of `index.html` into `public/app.js`, and every inline `onclick=` was replaced with delegated `data-nav` / `data-action` handlers — so the policy can genuinely be `script-src 'self'`.

Verified in-browser that the policy is **enforced**, not merely present: an injected inline `<script>` did not execute, and an external CDN script was blocked.

## Reviewing this

**15 files, +1465 / −397.** Suggested order:

1. **`src/lib/secret.js`** (+57) — the critical fix. Start here.
2. **`src/auth.js`** — secret wiring, 7d expiry, `X-API-Key` support, constant-time key compare.
3. **`src/lib/safe-url.js`** (+127) — SSRF guards. The blocked-CIDR table is the security-critical part.
4. **`src/routes/submit.js`** — SSRF call site, origin comparison, email escaping, IP validation.
5. **`src/lib/rate-limit.js`** (+89) and **`src/lib/escape.js`** (+37) — small, self-contained.
6. **`src/routes/forms.js`**, **`src/routes/users.js`**, **`src/server.js`** — call sites and app wiring.
7. **`test/security.js`** (+450) — one test per finding; useful as a spec for what each fix claims.

⚠️ **`public/app.js` (+301) and `public/index.html` (−266) are mostly a file move,** not new logic. Reviewing them as a rename with `esc()` added at seven sinks is far faster than reading 300 new lines. `git diff -M` or a whitespace-blind diff helps.

## Testing

```bash
npm ci
npm start
npm run test:security -- http://localhost:3000
```

The rate-limit tests (S3) need an instance running **shipped default budgets**, while the rest of the suite needs headroom so it isn't throttled by its own traffic. Run two instances and pass both:

```bash
RATE_LIMIT_AUTH_IP=5000 RATE_LIMIT_AUTH_ACCOUNT=5000 RATE_LIMIT_SUBMIT=5000 PORT=3030 DATA_DIR=/tmp/fb-loose npm start
PORT=3031 DATA_DIR=/tmp/fb-strict npm start
node test/security.js http://localhost:3030 http://localhost:3031
```

Set `FORMBASE_DATA_DIR` to a locally-readable data dir to additionally exercise S2d, which writes a private webhook URL straight into SQLite to prove the fetch-time guard holds independently of API validation.

## ⚠️ Deployment notes — behaviour changes on upgrade

- **All existing tokens are invalidated** on instances that relied on the default JWT secret; users must log in again. This is the point of S1.
- `JWT_SECRET`, if set, must now be **≥32 characters** and must not be the old published literal, or **the process refuses to start**.
- Token lifetime **30d → 7d** (`JWT_EXPIRES_IN` to override).
- `/api/health` **no longer returns counts** — update monitoring that parsed them; use authenticated `/api/health/stats`.
- `/api` is **same-origin only** unless `CORS_ORIGIN` is set. `/f` stays cross-origin by design.
- **`webhook_url` pointing at private or loopback addresses is now rejected.** Set `SSRF_ALLOW_PRIVATE=1` if your webhook target is genuinely on the LAN.
- `redirect_url` must be http or https.
- **Set `TRUST_PROXY`** when running behind a reverse proxy, or rate limiting keys off the proxy's address. Unset is the safe default.
- Rate limits may be tight where many users share a source IP — tune with `RATE_LIMIT_AUTH_IP` / `RATE_LIMIT_AUTH_ACCOUNT` / `RATE_LIMIT_SUBMIT` / `RATE_LIMIT_API`.
- **Bind-mounted data directories must be chowned to uid 1000** now that the container runs as non-root. Named volumes inherit ownership automatically.
- Emails are normalised to lowercase on write; lookups use `COLLATE NOCASE` so accounts created before this change can still log in.

## Not fixed

- **S16 — token revocation (partial).** Lifetime cut 30d → 7d, but true revocation needs a token-version column checked per request. Regenerating an API key still does not invalidate outstanding JWTs.
- **S20 — JWT in `localStorage` (accepted).** Moving to httpOnly cookies would trade XSS exfiltration for CSRF surface and a significantly larger refactor. Materially reduced by the CSP in S9.
- **User enumeration on registration** remains by design — a duplicate registration still returns 409. Removing it needs an email-verification flow. It is now rate limited.
- **DNS rebinding** against the SSRF guard is narrowed but not eliminated: addresses are validated after resolution, but a TOCTOU window remains between the check and the connection. Closing it fully requires pinning the socket to the validated IP.
Adding a docker compose file with variables in an environment file to…
Copilot AI review requested due to automatic review settings July 29, 2026 01:57
@SluberskiHomeLab

Copy link
Copy Markdown
Author

This is from my fork, thought you might want to see what I did to it to make it function and to solve some critical vulnerabilities.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request hardens FormBase against a wide range of security issues identified in a security review (auth hardening, SSRF defenses, rate limiting, output escaping/XSS defenses, safer CSV export, and safer defaults), and adds Docker Compose–based deployment docs/config for easier self-hosting.

Changes:

  • Reworked authentication and request handling to remove unsafe JWT secret fallback, add API-key auth wiring, reduce JWT lifetime, and prevent internal error leakage.
  • Added SSRF protections for webhooks/redirects, added rate limiting, and added output escaping/CSV export hardening (CSP, XSS and CSV injection defenses).
  • Added Dockerfile + docker-compose.yml + .env.example and a security regression test suite (test/security.js) to validate fixes against a running instance.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/security.js Adds a live-instance security regression suite (one test per finding) to prevent regressions.
src/server.js Centralizes security posture: CSP via Helmet, request size limits, /api routing behavior, error handler, and health endpoint changes.
src/routes/users.js Adds credential validation, rate limiting integration, email normalization, and timing-equalized auth flow.
src/routes/submit.js Adds SSRF-safe webhook delivery, strict origin allowlist matching, email escaping, IP validation, and submission rate limiting.
src/routes/forms.js Adds URL scheme validation for stored URLs, CSV export hardening, safer pagination coercion, and API-key-capable auth middleware.
src/lib/secret.js Implements safe JWT secret resolution (reject known-bad values; persist generated secret to disk with restrictive permissions).
src/lib/safe-url.js Introduces URL validation + DNS-resolution-based private-address blocking to mitigate SSRF.
src/lib/rate-limit.js Wires rate-limiter-flexible into auth, submissions, and general API limiting.
src/lib/escape.js Adds HTML escaping, CSV cell neutralization, and safe Content-Disposition generation helpers.
src/auth.js Removes hardcoded JWT secret fallback, adds API-key auth support, constant-time key comparison, and configurable JWT expiry.
readme.md Updates Node version guidance and documents Docker Compose recommended setup and key security-related env vars.
public/index.html Removes large inline dashboard script to support strict CSP; loads public/app.js.
public/app.js New extracted dashboard SPA script with escaping and non-inline event handlers to comply with CSP and reduce XSS risk.
package.json Updates engines requirement, bumps dependencies, and adds test:security script.
package-lock.json Updates lockfile to reflect dependency/version changes (including license and engine metadata).
Dockerfile Adds multi-stage build and runs container as non-root with persistent data directory ownership handling.
dockerfile Removes old lowercase dockerfile in favor of the new Dockerfile.
docker-compose.yml Adds a Compose setup with persistent named volume, healthcheck, and env passthrough for configuration.
.env.example Documents all supported runtime configuration values for Compose-based deployments.
Comments suppressed due to low confidence (2)

src/lib/rate-limit.js:86

  • clearAuthAttempts() deletes the entire per-IP bucket on any successful auth/register. That allows an attacker to reset the IP limiter by successfully authenticating to any account, then continue guessing passwords against other accounts from the same IP. Consider only clearing the per-account limiter (or rewarding points) rather than deleting the IP bucket.
async function clearAuthAttempts(req) {
  const account = String(req.body?.email || '').toLowerCase().slice(0, 254);
  try {
    await limiters.authIp.delete(clientKey(req));
    if (account) await limiters.authAccount.delete(account);
  } catch { /* best effort */ }

src/lib/rate-limit.js:73

  • Per-account rate limiting keys off req.body.email without trimming whitespace. Login normalizes with trim().toLowerCase(), so an attacker can bypass the account bucket by adding trailing/leading spaces to the email while still hitting the same account in the DB lookup.
  return async (req, res, next) => {
    const account = String(req.body?.email || '').toLowerCase().slice(0, 254);
    try {
      await limiters.authIp.consume(clientKey(req));
      if (account) await limiters.authAccount.consume(account);
      next();

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/rate-limit.js
Comment thread src/routes/submit.js
Fixing an origin issue with Proxies
…Also fixing an SMTP error where the From address was not updating from the default
Fixing some registration logic by setting up proper user management. …
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.

2 participants