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
View File
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
import { getStudyUserFromRequest } from '../study-helpers.js'
@@ -208,7 +209,17 @@ export function recordVisitor(req, res, overridePath = null, overrideReferrer =
}
}
const analyticsWriteLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, reason: 'rate-limited' },
})
export function register(app) {
app.use(['/api/analytics/play', '/api/analytics/event'], analyticsWriteLimiter)
app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true
setConsentCookie(res, consent)
+26 -6
View File
@@ -4,8 +4,21 @@ import { MAX_QUESTIONS } from '../config.js'
import { state } from '../state.js'
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
import { buildAbsoluteUrl } from '../helpers.js'
import { buildAbsoluteUrl, getClientIp } from '../helpers.js'
import { Resend } from 'resend'
import rateLimit from 'express-rate-limit'
const upvoteRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many votes. Please try again later.' },
})
// One upvote per client per question until the process restarts.
const upvotedBy = new Set()
const UPVOTED_BY_MAX = 50000
function ensureDraftQuestions() {
if (state.draftQuestions !== null) return
@@ -148,15 +161,22 @@ export function register(app) {
})
// Anonymous upvote — no auth required, lightweight increment
app.post('/api/questions/:id/upvote', (req, res) => {
app.post('/api/questions/:id/upvote', upvoteRateLimiter, (req, res) => {
const { id } = req.params
// Look in live questions first, then draft
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
const question = liveQ ?? draftQ
// Only approved questions are public, so only they can be upvoted.
const source = state.draftQuestions ?? state.questions
const question = source.find(q => q.id === id && q.isApproved === true)
const liveQ = question && state.questions.includes(question) ? question : null
const draftQ = question && state.draftQuestions?.includes(question) ? question : null
if (!question) {
res.status(404).json({ message: 'Question not found.' }); return
}
const voteKey = `${getClientIp(req)}|${id}`
if (upvotedBy.has(voteKey)) {
res.json({ ok: true, upvotes: question.upvotes ?? 0, duplicate: true }); return
}
if (upvotedBy.size >= UPVOTED_BY_MAX) upvotedBy.clear()
upvotedBy.add(voteKey)
question.upvotes = ((question.upvotes ?? 0) + 1)
if (liveQ) queueQuestionsWrite()
if (draftQ) queueDraftQuestionsWrite()
+3 -8
View File
@@ -21,6 +21,7 @@ import {
import {
requireStudyAuth,
hashStudyPassword,
verifyStudyPassword,
hashEmailChangeToken,
normalizeStudyUsername,
isValidStudyUsername,
@@ -173,10 +174,7 @@ export function register(app) {
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
const currentHash = hashStudyPassword(currentPassword)
const a = Buffer.from(currentHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
@@ -378,10 +376,7 @@ export function register(app) {
return
}
const currentHash = hashStudyPassword(currentPassword)
const a = Buffer.from(currentHash, 'utf8')
const b = Buffer.from(user.passwordHash, 'utf8')
if (a.length !== b.length || !timingSafeEqual(a, b)) {
if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
+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
}