40a8a9882a
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.
81 lines
3.9 KiB
JavaScript
81 lines
3.9 KiB
JavaScript
/**
|
|
* POST /api/auth/password-strength is unauthenticated and feeds its body into
|
|
* zxcvbn, whose matching is superlinear and runs synchronously on the event
|
|
* loop. Behind express.json({ limit: '50mb' }) that made a single request a
|
|
* whole-process denial of service: measured on this codebase, 1,000 characters
|
|
* blocked for ~5 seconds and 5,000 did not return in two minutes.
|
|
*
|
|
* The control is the length cap inside validatePassword(), so it holds for
|
|
* every caller. These tests pin the cap itself rather than the route, and use
|
|
* a wall-clock ceiling that only an unbounded zxcvbn call can breach.
|
|
*/
|
|
const { validatePassword, MAX_PASSWORD_LENGTH } = require('../../src/utils/passwordValidation');
|
|
|
|
describe('password validation length cap (zxcvbn DoS)', () => {
|
|
it('rejects an over-length password without doing superlinear work', () => {
|
|
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH); // 4x the cap
|
|
const started = Date.now();
|
|
const result = validatePassword(huge);
|
|
const elapsed = Date.now() - started;
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.join(' ')).toMatch(/at most 128 characters/);
|
|
// Unbounded, this input would not return for minutes.
|
|
expect(elapsed).toBeLessThan(250);
|
|
});
|
|
|
|
it('is bounded at the cap itself, the worst input it will still analyse', () => {
|
|
const atCap = 'aA1!'.repeat(MAX_PASSWORD_LENGTH / 4);
|
|
expect(atCap).toHaveLength(MAX_PASSWORD_LENGTH);
|
|
|
|
// 128 was chosen so the worst input the validator will still analyse costs
|
|
// about as much as an ordinary request (~41ms measured); 512 cost 1.4s.
|
|
const started = Date.now();
|
|
validatePassword(atCap);
|
|
expect(Date.now() - started).toBeLessThan(1000);
|
|
});
|
|
|
|
it('still accepts an ordinary strong password', () => {
|
|
const result = validatePassword('Tr0ub4dour&3-horse-battery');
|
|
expect(result.valid).toBe(true);
|
|
});
|
|
|
|
it('does not spin when a caller asks for a length the cap forbids', async () => {
|
|
// Codex review. generateSecurePassword retried by recursing on any invalid
|
|
// candidate, so the new cap made every candidate invalid for length > 128
|
|
// and turned the call into unbounded recursion. It now refuses up front,
|
|
// and the retry loop is bounded.
|
|
const { generateSecurePassword } = require('../../src/utils/passwordValidation');
|
|
|
|
expect(generateSecurePassword({ length: 16 })).toHaveLength(16);
|
|
expect(generateSecurePassword({ length: MAX_PASSWORD_LENGTH }))
|
|
.toHaveLength(MAX_PASSWORD_LENGTH);
|
|
expect(() => generateSecurePassword({ length: MAX_PASSWORD_LENGTH + 1 }))
|
|
.toThrow(/at most 128/);
|
|
});
|
|
|
|
it('does not echo the rejected password back in the error body', async () => {
|
|
// Codex review round 2. express-validator's errors.array() carries the
|
|
// submitted `value`, so the 400 for an oversized password returned the
|
|
// password itself -- reflecting a credential, and re-allocating up to the
|
|
// 50mb body limit on an unauthenticated endpoint, which partly undid the
|
|
// DoS fix this branch exists for.
|
|
const src = require('fs').readFileSync(
|
|
require('path').join(__dirname, '../../src/routes/auth.js'), 'utf8');
|
|
|
|
// No route may hand errors.array() straight to the response.
|
|
expect(src).not.toMatch(/errors:\s*errors\.array\(\)/);
|
|
// ...and the shared helper that replaces it must drop `value`.
|
|
const helper = require('fs').readFileSync(
|
|
require('path').join(__dirname, '../../src/utils/routeHelpers.js'), 'utf8');
|
|
expect(helper).toMatch(/safeValidationErrors\s*=\s*\(errors\)\s*=>\s*errors\.array\(\)\.map\(\(\{ value, \.\.\.rest \}\)/);
|
|
});
|
|
|
|
it('applies the cap through the context wrapper too', async () => {
|
|
const { validatePasswordInContext } = require('../../src/utils/passwordValidation');
|
|
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH);
|
|
const result = await validatePasswordInContext(huge, 'admin', {});
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
});
|