fix(security): bound password input before zxcvbn, and drop the legacy media mounts

Two findings from the GHSA-pwx6-5pqc-c5xq scan bundle, both verified against
the code and reproduced before fixing.

**Unauthenticated denial of service via password strength (csf_495d53fa).**
POST /api/auth/password-strength takes `body('password').notEmpty()` with no
upper bound, sits behind express.json({ limit: '50mb' }), and hands the string
to zxcvbn, whose matching is superlinear and runs synchronously on the event
loop. Measured on this codebase, in ms of blocked loop: 128 -> 41, 512 -> 1367,
1000 -> 5097, 5000 -> did not return in two minutes. One unauthenticated
request of about a kilobyte stops the whole process for five seconds; a few
kilobytes stops it indefinitely. The /api/auth rate limit does not help when a
single request is already enough.

The cap lives in validatePassword() so it covers every caller, present and
future; the route validator is defence in depth. 128 keeps the worst case at
the cost of an ordinary request while staying far above any real password --
bcrypt consumes only the first 72 bytes, so length past that adds no entropy to
the stored hash anyway. This is the only unauthenticated reach into zxcvbn:
setup is token-gated and self-closing, and acceptInvite/adminAuth use the
regex-only validator in passwordGenerator.

**The /photos and /thumbnails static mounts (csf_9aa6afe6, csf_559cd5cc,
csf_b14d462e, csf_547d26fa, and the gallery half of csf_34e420af).**
They served the raw originals and thumbnail trees behind photoAuth, which
authorises on a slug match. A static file server cannot apply per-photo rules,
so everything the gallery API decides was absent: allow_downloads, per-category
allow_downloads, watermarking, the resolution cap, reveal-mode windows,
visibility='hidden', download logging, and the customer-assignment re-check
that makes revocation immediate. photoAuth also bcrypt-compares an
x-gallery-password header per request with no limiter -- both rate-limit gates
return early for non-/api paths -- so the mount was an unmetered password
oracle. The filenames needed to drive all of this are handed to every guest in
the photos listing.

Nothing builds these URLs: no reference in frontend/src, none in the email
templates, and the only backend mentions are the /api/admin/photos/... API
routes and a maintenance-mode prefix list. The equivalent authorised routes are
/api/gallery/:slug/photo/:id and /thumbnail/:id. nginx still proxies the two
locations; they now 404, which is the intent.

**The /uploads mount (csf_1fc92f57).** It exposed the whole uploads/ root with
no auth middleware at all, and that root also holds signed contract PDFs
(uploads/contracts/signed) and client transfer files (uploads/transfers/<id>),
reachable by anyone who learned or guessed a filename. Narrowed to the two
public asset trees it exists for; contracts and transfers keep their own
authorised routes.

Removing the mounts leaves src/middleware/photoAuth.js unreferenced by
application code. Left in place deliberately -- deleting it and its tests is a
separate cleanup, and a smaller diff backports more safely.

Backend suite: 2742 passed.
This commit is contained in:
Paul Nothaft
2026-09-03 09:25:53 +02:00
parent a87e688484
commit 14cd5eacb3
4 changed files with 115 additions and 8 deletions
+28 -7
View File
@@ -596,14 +596,35 @@ const secureStatic = require('./src/middleware/secureStatic');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
// The /photos and /thumbnails static mounts are gone.
//
// They served the raw originals tree and the thumbnail tree behind photoAuth
// alone, which authorises on a slug match. A static file server cannot apply
// the rules the gallery API applies per photo, so everything the API decides
// was simply absent here: allow_downloads, per-category allow_downloads,
// watermarking, the resolution cap, reveal-mode windows, visibility='hidden',
// download logging, and the customer-assignment re-check that lets an admin
// revoke access immediately. The filenames needed to exercise it are handed to
// every guest in the photos listing.
//
// Nothing builds these URLs: no reference in frontend/src, none in the email
// templates, and the only backend mentions are the /api/admin/photos/... API
// routes and a maintenance-mode prefix list. nginx still proxies /photos and
// /thumbnails; those locations now 404, which is the intended outcome.
//
// Serving these safely would mean reimplementing per-photo authorisation and
// image processing inside a static handler -- i.e. the gallery API, which
// already exists at /api/gallery/:slug/photo/:id and /thumbnail/:id.
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Static file serving for uploads.
//
// Narrowed to the two public asset trees. The mount used to expose the whole
// uploads/ root with no auth middleware at all, and that root also holds
// signed contract PDFs (uploads/contracts/signed) and client transfer files
// (uploads/transfers/<id>) -- both reachable by anyone who learned or guessed
// a filename. Those are served by their own authorised routes.
app.use('/uploads/logos', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/logos')));
app.use('/uploads/favicons', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/favicons')));
// Static file serving for self-hosted webfonts (public — gallery visitors
// load these via @font-face). Replaces the previous Google Fonts CDN