private sites v1: security + review fixes (ssrf, csrf, __Host cookie) (#26)
private sites v1: security + review fixes (ssrf, csrf, __Host cookie)
54 unmodified lines555657585954 unmodified linespackage-lock.json**/*.bun
server.env.dev90 unmodified lines9192939495959697989899100101102103104105106107115 unmodified linesExpand all223224225226222227228229230231232233272 unmodified linesExpand all90 unmodified lines await pruneSessions()}
// Run maintenance on startuprunMaintenance()runMaintenance().catch((err) => logger.error('[Maintenance] Periodic maintenance failed', err))
// Schedule maintenance to run every hoursetInterval(runMaintenance, 60 * 60 * 1000)setInterval( () => { runMaintenance().catch((err) => logger.error('[Maintenance] Periodic maintenance failed', err)) }, 60 * 60 * 1000,)
startPrivateSiteReaper()
// Start DNS verification worker (runs every 10 minutes)115 unmodified linesExpand all error: errorMessage, statusCode, } }) .use(csrfProtection()) // Private-site subdomains (<siteId>.priv.<host>) legitimately POST /private/redeem // to redeem audience-scoped shares, so allow that origin through CSRF. .onBeforeHandle(csrfProtection([`.${process.env.PRIVATE_HOST || `priv.${BASE_HOST}`}`])) .get('/', async ({ request, set }) => { // Build dynamic login URL for AT Protocol OAuth entryway const loginUrl = isLocalDev ? `${new URL(request.url).origin}/api/auth/login` : `${config.domain}/api/auth/login` const atprotoLoginUrl = `https://atproto.wisp.place/?next=${encodeURIComponent(loginUrl)}`272 unmodified linesExpand all232 unmodified linesExpand all2332342352362372372382392402416 unmodified lines2482492502512522532542552562572582522532542552562572581 unmodified line232 unmodified linesExpand all expect(response.headers.get('ETag')).toBe('"test-checksum"') expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable') })
test('proxies absolute 200 rewrite targets with inline query placeholders', async () => { test('rejects absolute 200 rewrite targets without proxying them', async () => { storeFile( '_redirects', '/.well-known/webfinger?resource=:resource https://webfinger.madoka-winter.workers.dev/?resource=:resource 200', 'text/plain',6 unmodified lines 'https://madoka.example/.well-known/webfinger?resource=acct:ana@example.com', { accept: 'application/jrd+json' }, )
expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toContain('application/jrd+json') expect(await response.json()).toEqual({ subject: 'acct:ana@example.com' }) expect(safeFetchCalls).toHaveLength(1) expect(safeFetchCalls[0]?.url).toBe( 'https://webfinger.madoka-winter.workers.dev/?resource=acct%3Aana%40example.com', ) expect(response.status).toBe(400) expect(await response.text()).toBe('Absolute URL rewrites are not supported') expect(safeFetchCalls).toHaveLength(0) expect(storageGetWithMetadataKeys).not.toContain( `${DID}/${RKEY}/https://webfinger.madoka-winter.workers.dev/?resource=acct%3Aana%40example.com`, ) })1 unmodified line7 unmodified lines891011121213141598 unmodified lines114115116117119120121122123124125118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165128129130131343 unmodified linesExpand all475476477478517479480481482483311 unmodified linesExpand all795796797798837799800801802803283 unmodified linesExpand all7 unmodified linesimport { normalizeFileCids } from '@wispplace/fs-utils'import { isHtmlContent, rewriteHtmlPaths } from '@wispplace/fs-utils/html-rewriter'import type { Record as WispSettings } from '@wispplace/lexicons/types/place/wisp/settings'import { createLogger } from '@wispplace/observability'import { safeFetch } from '@wispplace/safe-fetch'import type { StorageResult } from '@wispplace/tiered-storage'import { lookup } from 'mime-types'import { isSiteUpdating } from './cache-invalidation'import { cache } from './cache-manager'98 unmodified linesfunction isAbsoluteHttpUrl(path: string): boolean { return path.startsWith('http://') || path.startsWith('https://')}
function getForwardedProxyHeaders(requestHeaders?: Record<string, string>): HeadersInit { const forwarded: Record<string, string> = {} for (const name of ['accept', 'accept-language', 'range']) { const value = requestHeaders?.[name] if (value) forwarded[name] = value } return forwardedfunction externalRewriteNotAllowedResponse(targetPath: string): Response { logger.warn('Blocked absolute URL for status 200 rewrite', { targetPath }) return new Response('Absolute URL rewrites are not supported', { status: 400, headers: { 'Cache-Control': 'no-store', }, })}
async function proxyExternalRewrite(targetPath: string, requestHeaders?: Record<string, string>): Promise<Response> { try { const upstream = await safeFetch(targetPath, { headers: getForwardedProxyHeaders(requestHeaders), maxSize: 10 * 1024 * 1024, retry: false, timeout: 30_000, }) const responseHeaders = new Headers(upstream.headers)
responseHeaders.delete('connection') responseHeaders.delete('keep-alive') responseHeaders.delete('proxy-authenticate') responseHeaders.delete('proxy-authorization') responseHeaders.delete('te') responseHeaders.delete('trailer') responseHeaders.delete('transfer-encoding') responseHeaders.delete('upgrade') if (!responseHeaders.has('Cache-Control')) { responseHeaders.set('Cache-Control', STANDARD_CACHE_CONTROL) }
return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders, }) } catch (err) { logger.warn('External rewrite proxy failed', { targetPath, error: err }) return new Response('Bad Gateway', { status: 502, headers: { 'Cache-Control': 'no-store', }, }) }}
function manifestHasPath(fileCids: Record<string, string> | null, filePath: string): boolean { return fileCids === null || fileCids[normalizeFilePath(filePath)] !== undefined}
343 unmodified linesExpand all
// Handle different status codes if (status === 200) { if (isAbsoluteHttpUrl(targetPath)) { const response = await proxyExternalRewrite(targetPath, headers) const response = externalRewriteNotAllowedResponse(targetPath) logTrace(trace, filePath || '/', logger) return response }
311 unmodified linesExpand all
// Handle different status codes if (status === 200) { if (isAbsoluteHttpUrl(targetPath)) { const response = await proxyExternalRewrite(targetPath, headers) const response = externalRewriteNotAllowedResponse(targetPath) logTrace(trace, filePath || '/', logger) return response }
283 unmodified linesExpand all67 unmodified lines68697071727374757677787980818283846 unmodified lines67 unmodified lines test('should reject when allowed hosts is empty', () => { expect(verifyRequestOrigin('https://example.com', [])).toBe(false) })
test('should allow private-site subdomain origins via dot-suffix allowlist', () => { // <siteId>.priv.wisp.place legitimately POSTs /private/redeem const allowed = ['.priv.wisp.place'] expect(verifyRequestOrigin('https://amber-anchor-fox-1234.priv.wisp.place', allowed)).toBe(true) expect(verifyRequestOrigin('https://priv.wisp.place', allowed)).toBe(false) expect(verifyRequestOrigin('https://evilpriv.wisp.place', allowed)).toBe(false) expect(verifyRequestOrigin('https://amber-anchor-fox-1234.priv.evil.com', allowed)).toBe(false) })
test('should handle IPv4 addresses', () => { expect(verifyRequestOrigin('http://127.0.0.1:8000', ['127.0.0.1:8000'])).toBe(true) expect(verifyRequestOrigin('http://192.168.1.1', ['192.168.1.1'])).toBe(true) })6 unmodified lines1123417 unmodified lines222324252726272829301 unmodified line32333435373836373839404142434445464748495045515253544950555657585960614 unmodified lines6667686963647071727374755 unmodified lines81828384787985import { Elysia } from 'elysia'import { logger } from './logger'
/** * CSRF Protection using Origin/Host header verification17 unmodified lines try { const originUrl = new URL(origin) const originHost = originUrl.host
return allowedHosts.some((host) => originHost === host) return allowedHosts.some((host) => originHost === host || (host.startsWith('.') && originHost.endsWith(host))) } catch { // Invalid URL return false }1 unmodified line
/** * CSRF Protection Middleware for Elysia * * Validates Origin header against Host header for non-GET requests * to prevent CSRF attacks when using cookie-based authentication. * NOTE: apply via `.onBeforeHandle(csrfProtection(...))` directly on the root * app instance, NOT wrapped in a `new Elysia({ name })` plugin. In Elysia * 1.4.x, lifecycle hooks from a named plugin `.use()`'d before route modules do * not propagate to those routes, leaving CSRF silently unenforced. Applying the * hook directly on the root instance propagates to all child `.use()` routes. * * `allowedExtraOrigins` permits additional exact hosts (e.g. the private-site * origin that legitimately POSTs /private/redeem). Entries beginning with `.` * match any subdomain of that suffix, so `<siteId>.priv.<host>` is covered. * * Usage: * ```ts * import { csrfProtection } from './lib/csrf' * * new Elysia() * .use(csrfProtection()) * .onBeforeHandle(csrfProtection(['.priv.example.com'])) * .post('/api/protected', handler) * ``` */export const csrfProtection = () => { return new Elysia({ name: 'csrf-protection' }).onBeforeHandle(({ request, set }) => {export const csrfProtection = (allowedExtraOrigins: string[] = []) => ({ request, set }: { request: Request; set: { status?: number | string } }) => { const method = request.method.toUpperCase()
// Only protect state-changing methods if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {4 unmodified lines const originHeader = request.headers.get('Origin') // Use X-Forwarded-Host if behind a proxy, otherwise use Host const hostHeader = request.headers.get('X-Forwarded-Host') || request.headers.get('Host')
// Validate origin matches host if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { // Validate origin matches host or an explicitly allowlisted origin if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader, ...allowedExtraOrigins])) { logger.warn('[CSRF] Request blocked', { method, origin: originHeader, host: hostHeader,5 unmodified lines error: 'CSRF validation failed', message: 'Request origin does not match host', } } })} }252 unmodified linesExpand all25325425525625725825926026126226326426519 unmodified lines252 unmodified linesExpand all 'idx_private_site_shares_token_hash', async () => db`CREATE INDEX IF NOT EXISTS idx_private_site_shares_token_hash ON private_site_shares(token_hash)`, ], [ 'idx_private_site_shares_token_hash_unique', async () => db`CREATE UNIQUE INDEX IF NOT EXISTS idx_private_site_shares_token_hash_unique ON private_site_shares(token_hash)`, ], [ 'idx_private_sessions_secret', async () => db`CREATE INDEX IF NOT EXISTS idx_private_sessions_secret ON private_site_sessions(secret_hash)`, ], [19 unmodified lines140 unmodified linesExpand all141142143144145146145146147148149150151152153154118 unmodified linesExpand all140 unmodified linesExpand all for (const file of prepared) { await writePrivateFile(site.siteId, file.path, file.bytes, file.mimeType) } } catch (err) { await deletePrivateSiteFiles(site.siteId).catch(() => 0) await deletePrivateSite(site.siteId).catch(() => false) await deletePrivateSiteFiles(site.siteId).catch((cleanupErr) => { logger.error('[PrivateSite] cleanup after failed ingest failed', cleanupErr, { siteId: site.siteId }) }) await deletePrivateSite(site.siteId).catch((cleanupErr) => { logger.error('[PrivateSite] cleanup after failed ingest failed', cleanupErr, { siteId: site.siteId }) }) throw err }
logger.info('[PrivateSite] Created', {118 unmodified linesExpand all93 unmodified lines94959697989910010110277 unmodified lines93 unmodified lines value: session.did, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 30 * 24 * 60 * 60, // 30 days })
// Revalidate the OAuth state token before returning a share visitor to its site.77 unmodified lines1234567818 unmodified lines272829303031323334353637import type { Context } from 'hono'import { routePath } from 'hono/route'import { logCollector, metricsCollector } from '../core'import { redactSecretPath } from '../redact'
/** * Hono middleware for observability * Tracks request metrics18 unmodified linesexport function observabilityErrorHandler(service: string) { return (err: Error, c: Context) => { const { pathname } = new URL(c.req.url)
logCollector.error(`Request failed: ${c.req.method} ${pathname}`, service, err, { statusCode: c.res.status || 500 }) logCollector.error(`Request failed: ${c.req.method} ${redactSecretPath(pathname)}`, service, err, { statusCode: c.res.status || 500, })
return c.text('Internal Server Error', 500) }}