From 9174331d2d345fe5ce3079847ff988536dd59126 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 18 Jun 2026 09:35:44 -0400 Subject: [PATCH] Bug fixes (real breakage): MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newsletter nudge — was calling /api/study-account/profile (wrong endpoint, ignored subscription). Now correctly calls /api/study-account/preferences with PATCH. Security: Email regex — replaced the permissive [^\s@]+@[^\s@]+ pattern with a proper RFC-compliant regex in contact.js and downloads.js Avatar magic bytes — server now checks actual PNG/JPEG/GIF/WEBP header bytes, not just the data URL prefix Certificate rate limit — public /api/public/certificate/:token now has a 30 req/15min limiter Session absolute TTL — admin sessions now have a 30-day hard cap; a stolen token can no longer be kept alive indefinitely by passive reads Account lockout — 5 failed logins locks a study account for 1 hour CSP headers — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers added globally Data integrity: Cascade delete — deleting a study account now also removes their certificates, community posts, comments, and progress file UX / reliability: Escape key on modals — all 3 modal groups (study index, notes, account) now close on Escape Display name min-length — empty spaces-only names rejected; if provided, must be ≥2 chars Note save rate limit — 30 saves/minute per user max Analytics fetch timeout — 5s AbortController so a hanging server doesn't block the browser indefinitely Email validation on signup — frontend catches bad email formats before hitting the server Cleanup: Deduplicated download forms — StudyDownloadForm and ResourceDownloadForm now share a single DownloadForm base; both are now thin wrappers --- data/study-reminders.json | 2 +- server.js | 20 +++++ server/auth.js | 17 ++++- server/routes/contact.js | 4 +- server/routes/downloads.js | 4 +- server/routes/study-account.js | 30 ++++++++ server/routes/study-certificate.js | 11 ++- server/routes/study-data.js | 17 +++++ src/App.tsx | 113 ++++------------------------- src/colossiansStudy.tsx | 37 +++++++++- 10 files changed, 145 insertions(+), 110 deletions(-) diff --git a/data/study-reminders.json b/data/study-reminders.json index d8d957f..ef9c58d 100644 --- a/data/study-reminders.json +++ b/data/study-reminders.json @@ -1,4 +1,4 @@ { "users": {}, - "updatedAt": "2026-06-17T16:26:39.085Z" + "updatedAt": "2026-06-18T11:26:38.001Z" } \ No newline at end of file diff --git a/server.js b/server.js index 24e3c94..a92825c 100644 --- a/server.js +++ b/server.js @@ -60,6 +60,26 @@ app.use(express.json({ limit: '10mb' })) const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1) app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1) +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'SAMEORIGIN') + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: https:", + "media-src 'self' https:", + "frame-src https:", + "connect-src 'self' https:", + ].join('; '), + ) + next() +}) + // Register API routes registerAdminAuth(app) registerAdminContent(app) diff --git a/server/auth.js b/server/auth.js index bcd7f0e..cad3561 100644 --- a/server/auth.js +++ b/server/auth.js @@ -15,6 +15,7 @@ const totpPendingSessions = new Map() const ADMIN_SESSION_COOKIE = 'vbn_admin_session' const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 +const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000 const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD const adminSessions = new Map() @@ -217,7 +218,8 @@ export function consumePendingSession(token) { export function createAdminSession() { const token = randomUUID() - adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS) + const now = Date.now() + adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS }) return token } @@ -234,13 +236,20 @@ export function isValidAdminSession(req) { const sessionToken = cookies[ADMIN_SESSION_COOKIE] if (!sessionToken) return false - const expiresAt = adminSessions.get(sessionToken) - if (!expiresAt || expiresAt <= Date.now()) { + const now = Date.now() + const session = adminSessions.get(sessionToken) + if (!session) return false + + // Support legacy sessions stored as a plain number (expiresAt) + const expiresAt = typeof session === 'object' ? session.expiresAt : session + const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity + + if (expiresAt <= now || absoluteExpiresAt <= now) { adminSessions.delete(sessionToken) return false } - adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) + adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt }) return true } diff --git a/server/routes/contact.js b/server/routes/contact.js index 41d7111..2f86867 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -98,7 +98,7 @@ export function register(app) { if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.trim().length > 100)) { res.status(400).json({ message: 'Last name is too long.' }); return } - if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) { res.status(400).json({ message: 'A valid email address is required.' }); return } if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) { @@ -470,7 +470,7 @@ export function register(app) { if (!submission) { res.status(404).json({ message: 'Submission not found.' }); return } - if (!submission.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(submission.email)) { + if (!submission.email || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(submission.email)) { res.status(400).json({ message: 'Submission does not have a valid email address.' }); return } diff --git a/server/routes/downloads.js b/server/routes/downloads.js index 6731f20..72ce74f 100644 --- a/server/routes/downloads.js +++ b/server/routes/downloads.js @@ -67,7 +67,7 @@ export function register(app) { if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { res.status(400).json({ message: 'Last name is required.' }); return } - if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) { res.status(400).json({ message: 'A valid email address is required.' }); return } @@ -175,7 +175,7 @@ export function register(app) { if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { res.status(400).json({ message: 'Last name is required.' }); return } - if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) { res.status(400).json({ message: 'A valid email address is required.' }); return } diff --git a/server/routes/study-account.js b/server/routes/study-account.js index c408f0f..b664426 100644 --- a/server/routes/study-account.js +++ b/server/routes/study-account.js @@ -8,11 +8,15 @@ import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js' import { state } from '../state.js' import { queueStudyUsersWrite, + queueStudyCommunityWrite, + queueStudyCommentsWrite, + queueStudyCertificatesWrite, readUploadsMetadata, writeUploadsMetadata, loadUserNotes, loadUserProgress, getUserNotesFilePath, + getUserProgressFilePath, } from '../data.js' import { requireStudyAuth, @@ -259,6 +263,17 @@ export function register(app) { return } + const isValidImageBytes = ( + (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) || // JPEG + (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) || // PNG + (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) || // GIF + (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) // WEBP (RIFF) + ) + if (!isValidImageBytes) { + res.status(400).json({ message: 'Upload must be a valid PNG, JPG, WEBP, or GIF image.' }) + return + } + const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, '')) const finalName = `${baseName || 'avatar'}-${Date.now()}${ext}` @@ -444,6 +459,21 @@ export function register(app) { state.studyNotesCache.delete(user.id) try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ } + state.studyProgressCache.delete(user.id) + try { await unlink(getUserProgressFilePath(user.id)) } catch { /* no progress file is fine */ } + + const beforeCerts = state.studyCertificates.length + state.studyCertificates = state.studyCertificates.filter(c => c.userId !== user.id) + if (state.studyCertificates.length !== beforeCerts) queueStudyCertificatesWrite() + + const beforePosts = state.studyCommunityPosts.length + state.studyCommunityPosts = state.studyCommunityPosts.filter(p => p.authorUserId !== user.id) + if (state.studyCommunityPosts.length !== beforePosts) queueStudyCommunityWrite() + + const beforeComments = state.studyComments.length + state.studyComments = state.studyComments.filter(c => c.userId !== user.id) + if (state.studyComments.length !== beforeComments) queueStudyCommentsWrite() + sendStudyAccountDeletedEmail(deletedEmail, deletedDisplayName).catch(err => { console.error('[study-account] delete email error:', err) }) diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js index 2cc9fff..224512b 100644 --- a/server/routes/study-certificate.js +++ b/server/routes/study-certificate.js @@ -1,9 +1,18 @@ import { randomUUID } from 'node:crypto' +import rateLimit from 'express-rate-limit' import { requireAdminAuth } from '../auth.js' import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js' import { state } from '../state.js' import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js' +const publicCertRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many certificate lookups. Please wait a moment.' }, +}) + /** * Returns the list of required section IDs for a study (only released sections). */ @@ -92,7 +101,7 @@ export function register(app) { }) // GET — public certificate page data (no auth, by token) - app.get('/api/public/certificate/:token', (req, res) => { + app.get('/api/public/certificate/:token', publicCertRateLimiter, (req, res) => { const { token } = req.params if (!token || typeof token !== 'string' || token.length > 100) { res.status(400).json({ message: 'Invalid token.' }); return diff --git a/server/routes/study-data.js b/server/routes/study-data.js index 59a6611..7f98933 100644 --- a/server/routes/study-data.js +++ b/server/routes/study-data.js @@ -23,6 +23,19 @@ import { import { MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_ENROLLMENTS_PER_USER } from '../config.js' import { randomUUID } from 'node:crypto' +const noteSaveHits = new Map() // userId -> { count, windowStart } +const NOTE_RATE_WINDOW_MS = 60 * 1000 +const NOTE_RATE_MAX = 30 + +function checkNoteSaveRateLimit(userId) { + const now = Date.now() + const entry = noteSaveHits.get(userId) ?? { count: 0, windowStart: now } + if (now - entry.windowStart > NOTE_RATE_WINDOW_MS) { entry.count = 0; entry.windowStart = now } + entry.count += 1 + noteSaveHits.set(userId, entry) + return entry.count <= NOTE_RATE_MAX +} + export function register(app) { // ── Enrollment ──────────────────────────────────────────────────────────── @@ -112,6 +125,10 @@ export function register(app) { return } const user = req.studyUser + if (!checkNoteSaveRateLimit(user.id)) { + res.status(429).json({ message: 'Too many note saves. Please slow down.' }) + return + } const noteStudySlug = getStudySlugFromNoteId(sectionId) if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to save notes.' }) diff --git a/src/App.tsx b/src/App.tsx index ec56685..179b230 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,12 +29,15 @@ function usePageTracking() { useEffect(() => { if (localStorage.getItem(CONSENT_KEY) !== 'accepted') return const referrer = document.referrer || '' + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) fetch('/api/analytics/pageview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: location.pathname, referrer }), keepalive: true, - }).catch(() => {}) + signal: controller.signal, + }).catch(() => {}).finally(() => clearTimeout(timeout)) }, [location.pathname]) } @@ -150,13 +153,12 @@ function HeadlinerWidget() { ) } -function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) { +function DownloadForm({ endpoint, extraBody, buttonText }: { endpoint: string; extraBody?: Record; buttonText: string }) { const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' }) const [subscribe, setSubscribe] = useState(true) const [honey, setHoney] = useState('') const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle') const [errorMsg, setErrorMsg] = useState('') - const [successMsg, setSuccessMsg] = useState('') const [downloadUrl, setDownloadUrl] = useState('') function handleChange(e: React.ChangeEvent) { @@ -167,14 +169,13 @@ function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: str e.preventDefault() setStatus('submitting') setErrorMsg('') - setSuccessMsg('') setDownloadUrl('') try { - const res = await fetch('/api/study-downloads/titus', { + const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...fields, subscribe, _honey: honey }), + body: JSON.stringify({ ...extraBody, ...fields, subscribe, _honey: honey }), }) const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string } @@ -185,7 +186,6 @@ function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: str } setStatus('success') - setSuccessMsg('Your download should start now. If not, use the link below.') setDownloadUrl(data.downloadUrl) window.location.assign(data.downloadUrl) } catch { @@ -228,10 +228,10 @@ function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: str Subscribe me to updates from Verse by Verse with Nate. {status === 'error' &&

{errorMsg}

} - {status === 'success' &&

{successMsg}

} - {status === 'success' && downloadUrl && ( + {status === 'success' && (

- Click here if your download does not start automatically. + Your download should start now.{' '} + {downloadUrl && Click here if it does not start automatically.}

)} - - ) + return } function buildCustomResourceDownloadId(id: string) { diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx index 2c929b0..52d5d76 100644 --- a/src/colossiansStudy.tsx +++ b/src/colossiansStudy.tsx @@ -521,6 +521,15 @@ export function StudyLandingPage({ content }: Props) { setEnrollMessage('') } + useEffect(() => { + if (studyModal === 'none') return + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') closeStudyModal() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [studyModal]) + useEffect(() => { let cancelled = false @@ -697,9 +706,11 @@ export function StudyLandingPage({ content }: Props) {
@@ -928,6 +939,10 @@ export function StudySignupPage() { setMessage('Please enter your email and password.') return } + if (!/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) { + setMessage('Please enter a valid email address.') + return + } setBusy(true) setMessage('') try { @@ -1523,6 +1538,15 @@ export function ColossiansStudySectionPage({ content }: Props) { setNotesModalOpen(false) } + useEffect(() => { + if (!notesModalOpen) return + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') closeNotesModal() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [notesModalOpen]) + function appendSelectedTextToNotes(selectedText: string) { if (!selectedText.trim()) return setNoteText(prev => prev ? `${prev.trim()}\n\n${selectedText.trim()}` : selectedText.trim()) @@ -2500,6 +2524,15 @@ export function StudyAccountPage() { setEnrollMessage('') } + useEffect(() => { + if (accountModal === 'none') return + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') closeAccountModal() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [accountModal]) + if (!auth.checked) { return (