Security & hardening pass: scrypt password hashing, persisted admin sessions, upvote/analytics rate limits, dependency fixes, healthcheck, lint cleanup; v2.10.0
Publish Container / build (push) Successful in 17m3s

- Study passwords: salted scrypt; legacy SHA-256 hashes verified and upgraded on login
- Admin sessions persisted (hashed tokens) so deploys no longer log the admin out
- Upvotes: rate limit + one per client per question, approved questions only; rate limit on analytics play/event
- npm audit: 46 -> 4 vulnerabilities (tiptap bumped to 3.31.4)
- Untrack per-user data (study users/notes/progress/etc.) from git
- Dockerfile HEALTHCHECK; node --test suite for password hashing
- Fix email template cards remounting (lost input focus) on each keystroke
- Lint: 34 errors -> 0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-09 08:45:39 -04:00
1 parent e2150e1cd7
commit 8a796964b9
27 files changed
+880 -1034

No files matched your search

+11 -9
View File
@@ -20,6 +20,8 @@ import {
normalizeStudyUsername,
isValidStudyUsername,
hashStudyPassword,
verifyStudyPassword,
studyPasswordNeedsRehash,
hashPasswordResetToken,
hashEmailVerifyToken,
findStudyUserByUsername,
@@ -46,6 +48,8 @@ import {
getCanonicalBaseUrl,
} from '../email.js'
const DUMMY_PASSWORD_HASH = hashStudyPassword('timing-equalizer')
const studyAuthRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
@@ -164,18 +168,19 @@ export function register(app) {
const user = findStudyUserByUsername(username)
if (!user) {
verifyStudyPassword(password, DUMMY_PASSWORD_HASH) // equalize timing with the real-user path
res.status(401).json({ message: 'Invalid email or password.' })
return
}
const submittedHash = hashStudyPassword(password)
const expectedHash = user.passwordHash
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(expectedHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
if (!verifyStudyPassword(password, user.passwordHash)) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
if (studyPasswordNeedsRehash(user.passwordHash)) {
user.passwordHash = hashStudyPassword(password)
queueStudyUsersWrite()
}
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
@@ -342,10 +347,7 @@ export function register(app) {
app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
const user = req.studyUser
const { password } = req.body ?? {}
const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '')
const a = Buffer.from(submittedHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
if (!verifyStudyPassword(typeof password === 'string' ? password : '', user.passwordHash)) {
res.status(401).json({ message: 'Incorrect password.' })
return
}