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

+6
View File
@@ -31,6 +31,12 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build - name: Run production build
run: npm run build run: npm run build
+6
View File
@@ -33,6 +33,12 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build - name: Run production build
run: npm run build run: npm run build
+13 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.9.8", "version": "2.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.9.8", "version": "2.10.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -18,6 +18,7 @@
"@tiptap/starter-kit": "^3.31.4", "@tiptap/starter-kit": "^3.31.4",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"docx": "^9.6.1", "docx": "^9.6.1",
"dompurify": "^3.4.16",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2", "fuse.js": "^7.4.2",
@@ -3829,7 +3830,7 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/unist": { "node_modules/@types/unist": {
@@ -5272,6 +5273,15 @@
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/duck": { "node_modules/duck": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.10.0", "version": "2.10.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -24,6 +24,7 @@
"@tiptap/starter-kit": "^3.31.4", "@tiptap/starter-kit": "^3.31.4",
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"docx": "^9.6.1", "docx": "^9.6.1",
"dompurify": "^3.4.16",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2", "fuse.js": "^7.4.2",
+16
View File
@@ -84,6 +84,10 @@ app.use((_req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'SAMEORIGIN') res.setHeader('X-Frame-Options', 'SAMEORIGIN')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000')
}
res.setHeader( res.setHeader(
'Content-Security-Policy', 'Content-Security-Policy',
[ [
@@ -147,6 +151,18 @@ app.use((req, res, next) => {
// Public routes (robots, sitemap, static, SPA fallback) // Public routes (robots, sitemap, static, SPA fallback)
registerPublic(app) registerPublic(app)
// JSON error responses for API routes instead of Express's default HTML page.
app.use((err, req, res, _next) => {
if (res.headersSent) return
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500
if (status >= 500) console.error('[error]', req.method, req.path, err)
if (req.path.startsWith('/api/')) {
res.status(status).json({ message: status >= 500 ? 'Internal server error.' : String(err?.message ?? 'Request failed.') })
return
}
res.status(status).type('text/plain').send(status >= 500 ? 'Internal server error.' : 'Bad request.')
})
const PORT = Number(process.env.PORT ?? 4173) const PORT = Number(process.env.PORT ?? 4173)
Promise.all([ Promise.all([
loadHitStatsFromDisk(), loadHitStatsFromDisk(),
+17
View File
@@ -0,0 +1,17 @@
import { writeFile as fsWriteFile, rename, unlink } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
// Drop-in replacement for fs.promises.writeFile that never leaves a truncated
// file behind: the data goes to a sibling temp file which is then renamed over
// the target (atomic on the same filesystem). A crash mid-write leaves the
// previous version intact instead of a half-written JSON file.
export async function writeFile(file, data, options) {
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
try {
await fsWriteFile(tmp, data, options)
await rename(tmp, file)
} catch (err) {
await unlink(tmp).catch(() => {})
throw err
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto' import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js' import { parseCookies, cookieSecureFlag } from './helpers.js'
+7 -3
View File
@@ -1,5 +1,7 @@
import { migrateScriptKeys } from './episode-script-keys.js' import { migrateScriptKeys } from './episode-script-keys.js'
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises' import { mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { assertPublicHttpsUrl } from './url-safety.js'
import path from 'node:path' import path from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { sanitizeSiteContent } from './helpers.js' import { sanitizeSiteContent } from './helpers.js'
@@ -533,12 +535,14 @@ export async function fireWebhooks(eventType, payload) {
if (targets.length === 0) return if (targets.length === 0) return
const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() }) const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() })
for (const wh of targets) { for (const wh of targets) {
fetch(wh.url, { // Re-check at delivery time: DNS may have changed since the webhook was registered.
assertPublicHttpsUrl(wh.url).then(() => fetch(wh.url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' }, headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
body, body,
redirect: 'manual',
signal: AbortSignal.timeout(8000), signal: AbortSignal.timeout(8000),
}) }))
.then(r => { .then(r => {
appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`) appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`)
}) })
+2 -1
View File
@@ -1,4 +1,5 @@
import { readFile, writeFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { sendNuggetsUploadNotification } from './email.js' import { sendNuggetsUploadNotification } from './email.js'
import { import {
SEAFILE_URL, SEAFILE_URL,
+10 -2
View File
@@ -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 { sanitizeSiteContent } from '../helpers.js'
import { requireAdminAuth, isValidAdminSession } from '../auth.js' import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { import {
@@ -524,12 +526,18 @@ export function register(app) {
res.json({ webhooks: state.webhooks }) 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 ?? {} const { url, events } = req.body ?? {}
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) { if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
res.status(400).json({ message: 'A valid HTTPS URL is required.' }) res.status(400).json({ message: 'A valid HTTPS URL is required.' })
return return
} }
try {
await assertPublicHttpsUrl(url.trim())
} catch (err) {
res.status(400).json({ message: `Webhook URL rejected: ${err.message}` })
return
}
if (state.webhooks.length >= 50) { if (state.webhooks.length >= 50) {
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' }) res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
return return
+25 -1
View File
@@ -1,6 +1,19 @@
import rateLimit from 'express-rate-limit'
import { BIBLE_BOOKS } from '../../shared/bible-books.js' import { BIBLE_BOOKS } from '../../shared/bible-books.js'
const passageCache = new Map() 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 // 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 // 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) { 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 book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim() const reference = String(req.query.reference ?? '').trim()
@@ -43,6 +56,16 @@ export function register(app) {
return 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}` const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) { if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(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') 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) passageCache.set(cacheKey, text)
res.json({ text }) res.json({ text })
} catch { } catch {
+4 -2
View File
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto' import { randomUUID, timingSafeEqual } from 'node:crypto'
import { Resend } from 'resend' import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js' import { requireAdminAuth } from '../auth.js'
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js' import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
@@ -311,7 +311,9 @@ export function register(app) {
|| String(req.query?.token || '').trim() || String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').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 res.status(401).json({ message: 'Unauthorized webhook.' }); return
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto' 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 path from 'node:path'
import multer from 'multer' import multer from 'multer'
import rateLimit from 'express-rate-limit' import rateLimit from 'express-rate-limit'
+50
View File
@@ -0,0 +1,50 @@
import { lookup } from 'node:dns/promises'
import net from 'node:net'
function isPrivateIPv4(ip) {
const [a, b] = ip.split('.').map(Number)
return (
a === 0 || a === 10 || a === 127
|| (a === 100 && b >= 64 && b <= 127) // CGNAT / Tailscale
|| (a === 169 && b === 254) // link-local, cloud metadata
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
|| a >= 224 // multicast / reserved
)
}
export function isPrivateAddress(ip) {
if (net.isIPv4(ip)) return isPrivateIPv4(ip)
if (net.isIPv6(ip)) {
const lower = ip.toLowerCase()
if (lower === '::' || lower === '::1') return true
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // unique local
if (/^fe[89ab]/.test(lower)) return true // link-local
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
if (mapped) return isPrivateIPv4(mapped[1])
return false
}
return true // not an IP at all: treat as unsafe
}
// Throws unless `rawUrl` is an https URL whose host resolves only to public
// addresses. Called when a webhook is registered and again before each delivery
// (DNS can change between the two).
export async function assertPublicHttpsUrl(rawUrl) {
let parsed
try {
parsed = new URL(rawUrl)
} catch {
throw new Error('Malformed URL.')
}
if (parsed.protocol !== 'https:') throw new Error('Only https:// URLs are allowed.')
if (parsed.username || parsed.password) throw new Error('URLs with embedded credentials are not allowed.')
const host = parsed.hostname.replace(/^\[|\]$/g, '')
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
throw new Error('Internal hostnames are not allowed.')
}
const addresses = net.isIP(host) ? [{ address: host }] : await lookup(host, { all: true })
if (addresses.length === 0 || addresses.some(a => isPrivateAddress(a.address))) {
throw new Error('URL resolves to a private or internal address.')
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import DOMPurify from 'dompurify'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable' import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
@@ -6868,7 +6869,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<details style={{ marginBottom: '1rem' }}> <details style={{ marginBottom: '1rem' }}>
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary> <summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
<div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }} <div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }}
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} /> dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)')) }} />
</details> </details>
)} )}
{bcAudience?.segmentConfigured === false && <p className="admin-save-error">RESEND_SEGMENT_ID isn’t set on the server, so there’s no Resend segment to send to.</p>} {bcAudience?.segmentConfigured === false && <p className="admin-save-error">RESEND_SEGMENT_ID isn’t set on the server, so there’s no Resend segment to send to.</p>}
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import DOMPurify from 'dompurify'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content' import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData' import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
@@ -2791,7 +2792,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
html?.trim() ? ( html?.trim() ? (
<div key={key} style={{ marginBottom: '1.25rem' }}> <div key={key} style={{ marginBottom: '1.25rem' }}>
<p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p> <p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: html }} /> <div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
</div> </div>
) : null ) : null
)) ))
+26
View File
@@ -0,0 +1,26 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, readdir, writeFile as plainWrite, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { writeFile } from '../server/atomic-write.js'
test('replaces file contents and leaves no temp files', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'atomic-'))
const file = path.join(dir, 'a.json')
await plainWrite(file, 'old')
await writeFile(file, '{"ok":true}', 'utf8')
assert.equal(await readFile(file, 'utf8'), '{"ok":true}')
assert.deepEqual(await readdir(dir), ['a.json'])
await rm(dir, { recursive: true })
})
test('a failed write keeps the old file and cleans up', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'atomic-'))
const file = path.join(dir, 'a.json')
await plainWrite(file, 'old')
await assert.rejects(() => writeFile(file, { not: 'a string' }))
assert.equal(await readFile(file, 'utf8'), 'old')
assert.deepEqual(await readdir(dir), ['a.json'])
await rm(dir, { recursive: true })
})
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { isPrivateAddress, assertPublicHttpsUrl } from '../server/url-safety.js'
test('private, loopback, link-local and tailscale addresses are flagged', () => {
for (const ip of ['127.0.0.1', '10.0.0.5', '192.168.1.1', '172.16.0.1', '169.254.169.254', '100.64.1.1', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) {
assert.ok(isPrivateAddress(ip), ip)
}
for (const ip of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) assert.ok(!isPrivateAddress(ip), ip)
})
test('assertPublicHttpsUrl rejects unsafe URLs', async () => {
for (const url of ['http://example.com', 'https://localhost/x', 'https://127.0.0.1/', 'https://169.254.169.254/latest', 'https://user:pw@example.com', 'https://nas.local/hook', 'not a url']) {
await assert.rejects(() => assertPublicHttpsUrl(url), url)
}
await assert.doesNotReject(() => assertPublicHttpsUrl('https://8.8.8.8/hook'))
})