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

+41 -5
View File
@@ -1,5 +1,6 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import { DATA_DIR } from './paths.js'
@@ -17,6 +18,38 @@ const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const adminSessions = new Map()
// Sessions survive restarts/deploys. Only a SHA-256 of each token is written to
// disk, so the file alone cannot be used to hijack a session. Sliding-expiry
// refreshes are not persisted; a restart reverts to the last persisted expiry.
const ADMIN_SESSIONS_FILE = path.join(DATA_DIR, 'admin-sessions.json')
let adminSessionsWrite = Promise.resolve()
function persistAdminSessions() {
const sessions = []
for (const [tokenHash, session] of adminSessions) {
if (session && typeof session === 'object') sessions.push({ tokenHash, ...session })
}
adminSessionsWrite = adminSessionsWrite
.then(() => writeFile(ADMIN_SESSIONS_FILE, JSON.stringify({ sessions }), { encoding: 'utf8', mode: 0o600 }))
.catch(err => { console.error('[admin-sessions] write failed:', err) })
}
// Loaded sessions are keyed by token hash; lookups hash the cookie value, so the
// in-memory map is keyed by hash throughout.
function adminSessionKey(token) {
return sha256(token)
}
try {
const parsed = JSON.parse(readFileSync(ADMIN_SESSIONS_FILE, 'utf8'))
const now = Date.now()
for (const e of Array.isArray(parsed?.sessions) ? parsed.sessions : []) {
if (typeof e?.tokenHash !== 'string' || typeof e.expiresAt !== 'number' || typeof e.absoluteExpiresAt !== 'number') continue
if (e.expiresAt <= now || e.absoluteExpiresAt <= now) continue
adminSessions.set(e.tokenHash, { expiresAt: e.expiresAt, absoluteExpiresAt: e.absoluteExpiresAt })
}
} catch { /* no persisted sessions */ }
export function sha256(value) {
return createHash('sha256').update(String(value)).digest('hex')
}
@@ -213,13 +246,15 @@ export function consumePendingSession(token) {
export function createAdminSession() {
const token = randomUUID()
const now = Date.now()
adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
adminSessions.set(adminSessionKey(token), { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
persistAdminSessions()
return token
}
export function deleteAdminSession(token) {
if (token) {
adminSessions.delete(token)
adminSessions.delete(adminSessionKey(token))
persistAdminSessions()
}
}
@@ -231,7 +266,8 @@ export function isValidAdminSession(req) {
if (!sessionToken) return false
const now = Date.now()
const session = adminSessions.get(sessionToken)
const key = adminSessionKey(sessionToken)
const session = adminSessions.get(key)
if (!session) return false
// Support legacy sessions stored as a plain number (expiresAt)
@@ -239,11 +275,11 @@ export function isValidAdminSession(req) {
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
if (expiresAt <= now || absoluteExpiresAt <= now) {
adminSessions.delete(sessionToken)
adminSessions.delete(key)
return false
}
adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
adminSessions.set(key, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
return true
}
+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
}
+35 -2
View File
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto'
import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'
import path from 'node:path'
import {
parseCookies,
@@ -56,10 +56,43 @@ export function getStudySlugFromNoteId(sectionId) {
// ── Password / token hashing ───────────────────────────────────────────────
export function hashStudyPassword(password) {
const LEGACY_HASH_RE = /^[0-9a-f]{64}$/
function legacyHashStudyPassword(password) {
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
}
// New hashes are `scrypt$<salt hex>$<hash hex>`. Legacy accounts hold an
// unsalted SHA-256 hex digest; verifyStudyPassword accepts both and
// studyPasswordNeedsRehash lets login upgrade them transparently.
export function hashStudyPassword(password) {
const salt = randomBytes(16)
const hash = scryptSync(String(password), salt, 64)
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
}
export function verifyStudyPassword(password, stored) {
if (typeof stored !== 'string' || !stored) return false
let expected
let actual
if (stored.startsWith('scrypt$')) {
const [, saltHex, hashHex] = stored.split('$')
if (!saltHex || !hashHex) return false
expected = Buffer.from(hashHex, 'hex')
actual = scryptSync(String(password), Buffer.from(saltHex, 'hex'), expected.length)
} else if (LEGACY_HASH_RE.test(stored)) {
expected = Buffer.from(stored, 'hex')
actual = Buffer.from(legacyHashStudyPassword(password), 'hex')
} else {
return false
}
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
export function studyPasswordNeedsRehash(stored) {
return typeof stored === 'string' && !stored.startsWith('scrypt$')
}
export function hashEmailChangeToken(token) {
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
}