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
}