Second hardening pass: atomic data writes, bible-passage limits, webhook SSRF guard, note sanitizing, CI gates; v2.10.1
Publish Container / build (push) Successful in 15m46s

- Atomic temp-file + rename for all JSON data writes (no truncated files on crash)
- /api/bible-passage: rate limit, range caps (60 verses), bounded cache
- Admin webhooks: https-only, block private/internal/Tailscale addresses (checked at registration and delivery), no redirects
- Resend webhook token compare is timing-safe
- DOMPurify on rendered study notes and broadcast preview
- CI runs lint + tests before build
- Permissions-Policy and HSTS (production) headers; JSON error handler for /api
- Tests for URL safety and atomic writes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-09 09:00:28 -04:00
1 parent 8a796964b9
commit 2a64f29a00
18 files changed
+209 -17

No files matched your search

+25 -1
View File
@@ -1,6 +1,19 @@
import rateLimit from 'express-rate-limit'
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
const passageCache = new Map()
const PASSAGE_CACHE_MAX = 2000
const MAX_CHAPTER = 150
const MAX_VERSE = 176
const MAX_VERSES_PER_REQUEST = 60
const passageRateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many passage requests. Please slow down.' },
})
// Keyed by lowercase, whitespace-stripped book name/alias (matching the format
// the admin's "Bible Book" field and the /api/bible-passage `book` query param
@@ -27,7 +40,7 @@ function parseReference(reference) {
}
export function register(app) {
app.get('/api/bible-passage', async (req, res) => {
app.get('/api/bible-passage', passageRateLimiter, async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim()
@@ -43,6 +56,16 @@ export function register(app) {
return
}
if (
parsed.chapter < 1 || parsed.chapter > MAX_CHAPTER
|| parsed.verseStart < 1 || parsed.verseEnd > MAX_VERSE
|| parsed.verseEnd < parsed.verseStart
|| parsed.verseEnd - parsed.verseStart + 1 > MAX_VERSES_PER_REQUEST
) {
res.status(400).json({ message: `Passage out of range (max ${MAX_VERSES_PER_REQUEST} verses).` })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) })
@@ -66,6 +89,7 @@ export function register(app) {
}
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
if (passageCache.size >= PASSAGE_CACHE_MAX) passageCache.delete(passageCache.keys().next().value)
passageCache.set(cacheKey, text)
res.json({ text })
} catch {