Files
picpeak/backend/src/routes/setup.js
T
Paul Nothaft 40a8a9882a fix(security): stop reflecting submitted values in validation errors everywhere, cap credential lengths, close the login timing oracle
safeValidationErrors moves to utils/routeHelpers and replaces every
res.status(400).json({ errors: errors.array() }) in the routes, so no 400
body carries the submitted value any more (setup, customer auth and
customer change-password were still echoing rejected passwords).

Admin login, gallery verify, customer login/register/reset, customer
change-password and setup now cap username/slug at 255 and passwords at
MAX_PASSWORD_LENGTH at the validator, so an oversized value never reaches
the lockout lookup, bcrypt or the failed-attempt log.

Admin and customer login run one bcrypt compare on every path; the unknown
account branch used to return in microseconds against ~100ms for a wrong
password, which enumerated usernames despite the generic message.
2026-09-03 10:53:30 +02:00

101 lines
4.1 KiB
JavaScript

'use strict';
// Public first-run setup endpoints. UNAUTHENTICATED by design — they exist so a
// fresh instance can create its first admin from the browser (no ADMIN_PASSWORD
// in .env). Both are hard-gated on "no admin exists yet", and POST /admin also
// requires the one-time setup token, so they self-close after setup. The POST is
// rate-limited at the mount point in server.js (authRateLimiter).
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { adminAuth } = require('../middleware/auth');
const logger = require('../utils/logger');
const router = express.Router();
router.get('/status', async (req, res) => {
try {
res.json(await setupService.getSetupStatus());
} catch (err) {
logger.error('[setup] status failed', { error: err.message });
res.status(500).json({ error: 'Failed to read setup status' });
}
});
// Step-1 pre-flight: validate the setup token without consuming it, so the
// two-step wizard can block "Continue" on a wrong token. Rate-limited at the
// mount point in server.js (authRateLimiter), same as POST /admin.
router.post('/verify-token', [
body('token').notEmpty().withMessage('Setup token is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
try {
const valid = await setupService.verifySetupToken(req.body.token);
if (!valid) {
return res.status(400).json({ error: 'Invalid setup token', field: 'token' });
}
return res.json({ valid: true });
} catch (err) {
if (err.statusCode) {
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
}
logger.error('[setup] verifyToken failed', { error: err.message });
return res.status(500).json({ error: 'Setup failed' });
}
});
router.post('/admin', [
body('token').notEmpty().withMessage('Setup token is required'),
body('email').isEmail().withMessage('A valid email is required'),
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH }).withMessage('Password is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
try {
const { token, email, password } = req.body;
const result = await setupService.createInitialAdmin({
token,
email,
password,
ip: getClientIp(req),
});
setAdminAuthCookie(res, result.token);
// Token delivered via HttpOnly cookie only (mirrors admin login).
res.status(201).json({ user: result.user });
} catch (err) {
if (err.statusCode) {
// `field` (token/email/password) lets the client show a translated
// message instead of rendering the raw English error verbatim.
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
}
logger.error('[setup] createInitialAdmin failed', { error: err.message });
return res.status(500).json({ error: 'Setup failed' });
}
});
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
// admin exists (the wizard is authenticated from the account step onward), so
// it takes the normal admin auth. One-way: while the flag is unset the seeded
// SYSTEM event types may be deleted from the wizard's event-types step; once
// set they are permanently protected (#800).
router.post('/complete', adminAuth, async (req, res) => {
try {
await setupService.markSetupWizardCompleted();
res.json({ completed: true });
} catch (err) {
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
res.status(500).json({ error: 'Failed to mark setup complete' });
}
});
module.exports = router;