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
Conversation
`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.
… keep things easy and clean.
Adding a docker compose file with variables in an environment file to…
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. |
There was a problem hiding this comment.
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.emailwithout trimming whitespace. Login normalizes withtrim().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.
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. …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
npm auditFindings
webhook_urlContent-Dispositionfilename/api/healthleaked instance statsredirect_urlapiKeyMiddlewarewas dead codelocalStorageThe three that matter most
S1 — Hardcoded JWT secret (CRITICAL)
src/auth.jsfell back to'formbase-dev-secret-change-in-production', a literal published in this repository. Any instance that did not setJWT_SECRETsigned 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 listedJWT_SECRETas optional with the default shown.Fixed in
src/lib/secret.js. No constant fallback exists.JWT_SECRETis 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 restartstill 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-endpointmade the server POST submission JSON to that loopback listener. On a cloud host this reaches169.254.169.254and 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=1for 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-flexiblewas 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 viaRATE_LIMIT_*. Successful auth refunds the bucket; 429s carryRetry-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.ipwas rendered unescaped in the dashboard. That is harmless whilereq.ipis the socket address — but rate limiting (S3) needstrust proxy, and enabling that makesX-Forwarded-Forattacker-controlled, turnings.ipinto stored XSS in the owner's dashboard, triggered by an unauthenticated submission.They ship together:
trust proxyis opt-in viaTRUST_PROXY, the server stores only anet.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, andX-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 ofindex.htmlintopublic/app.js, and every inlineonclick=was replaced with delegateddata-nav/data-actionhandlers — so the policy can genuinely bescript-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:
src/lib/secret.js(+57) — the critical fix. Start here.src/auth.js— secret wiring, 7d expiry,X-API-Keysupport, constant-time key compare.src/lib/safe-url.js(+127) — SSRF guards. The blocked-CIDR table is the security-critical part.src/routes/submit.js— SSRF call site, origin comparison, email escaping, IP validation.src/lib/rate-limit.js(+89) andsrc/lib/escape.js(+37) — small, self-contained.src/routes/forms.js,src/routes/users.js,src/server.js— call sites and app wiring.test/security.js(+450) — one test per finding; useful as a spec for what each fix claims.public/app.js(+301) andpublic/index.html(−266) are mostly a file move, not new logic. Reviewing them as a rename withesc()added at seven sinks is far faster than reading 300 new lines.git diff -Mor a whitespace-blind diff helps.Testing
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:
Set
FORMBASE_DATA_DIRto 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.JWT_SECRET, if set, must now be ≥32 characters and must not be the old published literal, or the process refuses to start.JWT_EXPIRES_INto override)./api/healthno longer returns counts — update monitoring that parsed them; use authenticated/api/health/stats./apiis same-origin only unlessCORS_ORIGINis set./fstays cross-origin by design.webhook_urlpointing at private or loopback addresses is now rejected. SetSSRF_ALLOW_PRIVATE=1if your webhook target is genuinely on the LAN.redirect_urlmust be http or https.TRUST_PROXYwhen running behind a reverse proxy, or rate limiting keys off the proxy's address. Unset is the safe default.RATE_LIMIT_AUTH_IP/RATE_LIMIT_AUTH_ACCOUNT/RATE_LIMIT_SUBMIT/RATE_LIMIT_API.COLLATE NOCASEso accounts created before this change can still log in.Not fixed
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.🤖 Generated with Claude Code