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
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:
1 parent
e2150e1cd7
commit
8a796964b9
27 files changed
+880
-1034
No files matched your search
+41
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user