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
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:
1 parent
8a796964b9
commit
2a64f29a00
18 files changed
+209
-17
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import { assertPublicHttpsUrl } from '../url-safety.js'
|
||||
import { sanitizeSiteContent } from '../helpers.js'
|
||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||
import {
|
||||
@@ -524,12 +526,18 @@ export function register(app) {
|
||||
res.json({ webhooks: state.webhooks })
|
||||
})
|
||||
|
||||
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
|
||||
app.post('/api/admin-webhooks', requireAdminAuth, async (req, res) => {
|
||||
const { url, events } = req.body ?? {}
|
||||
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
|
||||
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await assertPublicHttpsUrl(url.trim())
|
||||
} catch (err) {
|
||||
res.status(400).json({ message: `Webhook URL rejected: ${err.message}` })
|
||||
return
|
||||
}
|
||||
if (state.webhooks.length >= 50) {
|
||||
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
|
||||
return
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { Resend } from 'resend'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
|
||||
@@ -311,7 +311,9 @@ export function register(app) {
|
||||
|| String(req.query?.token || '').trim()
|
||||
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
|
||||
|
||||
if (!providedToken || providedToken !== expectedToken) {
|
||||
const tokenA = Buffer.from(providedToken, 'utf8')
|
||||
const tokenB = Buffer.from(expectedToken, 'utf8')
|
||||
if (!providedToken || tokenA.length !== tokenB.length || !timingSafeEqual(tokenA, tokenB)) {
|
||||
res.status(401).json({ message: 'Unauthorized webhook.' }); return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises'
|
||||
import { readFile, unlink, mkdir } from 'node:fs/promises'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import path from 'node:path'
|
||||
import multer from 'multer'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
Reference in new issue
Block a user