// ── Resolve data directory BEFORE loading .env ──────────── const { DATA_DIR, DB_PATH, ENV_PATH, CERTS_DIR, UPLOADS_DIR, DELETED_ATTACHMENTS_DIR, GAMES_ROMS_DIR } = require('./src/paths'); const { purgeDeletedAttachments, resolveDeletedRetentionDays } = require('./src/deletedAttachments'); const { trimUploadsToLimit } = require('./src/uploadsTrim'); // ── Node.js version guard ───────────────────────────────── const nodeMajor = parseInt(process.versions.node.split('.')[0], 10); if (nodeMajor < 22 || nodeMajor > 26) { console.error(`\n Haven requires Node.js 22-26. You have v${process.versions.node}.`); console.error(' If you installed Node.js from nodejs.org, make sure you picked the'); console.error(' LTS version (v22.x), not an older or unsupported version.'); console.error(' LTS download: https://nodejs.org/en/download (choose "LTS")\n'); process.exit(1); } // Bootstrap .env into the data directory if it doesn't exist yet const fs = require('fs'); const path = require('path'); // ── Stale-install guard ─────────────────────────────────── // Updating by unzipping/copying a release over an existing install leaves // behind files that newer versions deleted. That is normally harmless, until // the deleted file is a module that was split into a folder of the same name // (src/socketHandlers.js became src/socketHandlers/ in 2.9.8): require() // resolves the leftover FILE before the directory, so the server silently // runs months-old module code no matter how current every other file is, and // eventually dies somewhere unrelated. A real self-host crashed on boot with // "Cannot read properties of undefined (reading 'activity')" because a // pre-2.9.8 socketHandlers.js was still shadowing the folder, after months // of its socket layer being frozen at the old version while "fully updated". // Catch the pattern generically and say exactly which file to delete. { const srcDir = path.join(__dirname, 'src'); let entries = []; try { entries = fs.readdirSync(srcDir, { withFileTypes: true }); } catch { /* no src folder: the require() calls below fail loudly on their own */ } const dirNames = new Set(entries.filter(e => e.isDirectory()).map(e => e.name)); const stale = entries.filter(e => e.isFile() && e.name.endsWith('.js') && dirNames.has(e.name.slice(0, -3)) && fs.existsSync(path.join(srcDir, e.name.slice(0, -3), 'index.js')) ).map(e => path.join('src', e.name)); if (stale.length > 0) { console.error('\n❌ Stale file(s) from an older Haven install detected:\n'); for (const f of stale) console.error(` ${f}`); console.error('\n Each file above is left over from an old version and hides the'); console.error(' module folder of the same name, so this server would run with'); console.error(' outdated code and fail in confusing ways.'); console.error(' Fix: delete the file(s) listed above (the folders contain the'); console.error(' current code), or update by replacing the whole Haven folder'); console.error(' instead of copying new files over an old install. Your data is'); console.error(` safe: it lives in ${DATA_DIR}, not in the install folder.\n`); process.exit(1); } } // .env holds JWT_SECRET: readable by other accounts on a shared Linux host, // it would let them sign admin tokens. No effect on Windows. function _lockEnvFile() { try { fs.chmodSync(ENV_PATH, 0o600); } catch (err) { console.warn('Could not restrict .env permissions:', err.message); } } if (!fs.existsSync(ENV_PATH)) { const example = path.join(__dirname, '.env.example'); if (fs.existsSync(example)) { fs.copyFileSync(example, ENV_PATH); _lockEnvFile(); console.log(`📄 Created .env in ${DATA_DIR} from template`); } else { // Write a minimal .env so dotenv doesn't fail fs.writeFileSync(ENV_PATH, 'JWT_SECRET=change-me-to-something-random-and-long\n'); } } require('dotenv').config({ path: ENV_PATH }); // Also load the project root .env as an override source. // Docker compose injects it via env_file, but when running directly on the // host the data-directory .env may be stale (created before PUBLIC_URL was // added), so the root .env serves as a fallback for env vars the server // administrator has explicitly set. // // This must be done *after* ENV_PATH so the data-dir .env takes precedence // for server-generated values (JWT_SECRET, VAPID keys). const rootEnv = path.join(__dirname, '.env'); if (fs.existsSync(rootEnv)) { require('dotenv').config({ path: rootEnv, override: false }); console.log('📄 Loaded project root .env as supplementary source'); } // Send outgoing requests through the host's proxy (https_proxy / http_proxy / // no_proxy) when one is set. Without one this does nothing. require('./src/outboundProxy').install(); const express = require('express'); const { createServer } = require('http'); const { createServer: createHttpsServer } = require('https'); const { Server } = require('socket.io'); const crypto = require('crypto'); const helmet = require('helmet'); const multer = require('multer'); const { sniffImageType } = require('./src/imageMetadata'); const diskGuard = require('./src/diskGuard'); // (#5505) Refuse uploads that would eat into the reserved disk headroom, so a // full volume can never leave admins unable to delete the files that filled it. const uploadDiskGuard = diskGuard.guardUploads(); console.log(`📂 Data directory: ${DATA_DIR}`); // ── Auto-generate JWT secret (MUST happen before loading auth module) ── if (process.env.JWT_SECRET === 'change-me-to-something-random-and-long' || !process.env.JWT_SECRET) { const generated = crypto.randomBytes(48).toString('base64'); let envContent = fs.readFileSync(ENV_PATH, 'utf-8'); envContent = envContent.replace(/JWT_SECRET=.*/, `JWT_SECRET=${generated}`); fs.writeFileSync(ENV_PATH, envContent, { mode: 0o600 }); _lockEnvFile(); process.env.JWT_SECRET = generated; console.log('🔑 Auto-generated strong JWT_SECRET (saved to .env)'); } // ── Auto-generate VAPID keys for push notifications ────── const webpush = require('web-push'); if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) { const vapidKeys = webpush.generateVAPIDKeys(); let envContent = fs.readFileSync(ENV_PATH, 'utf-8'); envContent += `\nVAPID_PUBLIC_KEY=${vapidKeys.publicKey}\nVAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`; fs.writeFileSync(ENV_PATH, envContent, { mode: 0o600 }); _lockEnvFile(); process.env.VAPID_PUBLIC_KEY = vapidKeys.publicKey; process.env.VAPID_PRIVATE_KEY = vapidKeys.privateKey; console.log('🔔 Auto-generated VAPID keys for push notifications (saved to .env)'); } // Configure web-push with contact email (admin can override via VAPID_EMAIL in .env) const vapidEmail = process.env.VAPID_EMAIL || 'mailto:admin@haven.local'; webpush.setVapidDetails(vapidEmail, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY); const { initDatabase } = require('./src/database'); const { router: authRoutes, authLimiter, verifyToken } = require('./src/auth'); const { setupSocketHandlers, sanitizeText, sanitizeSoundName, sanitizeBorderTransform, toReplyContext } = require('./src/socketHandlers'); const { initFerry, stopFerry } = require('./src/ferry'); const { threadExtras: forumBlogThreadExtras } = require('./src/forumBlog'); const { canAccessVoiceChannel, getAccessibleVoiceChannels } = require('./src/botVoice'); const { CREATOR_NOT_BANNED_SQL } = require('./src/botAccess'); const { cleanWebhookName, cleanWebhookAvatarUrl } = require('./src/webhookFields'); const { validHighScore, isKnownGame } = require('./src/highScores'); const protectedMessages = require('./src/protectedMessages'); const { BotAudioManager, inspectAudioFile, MAX_AUDIO_BYTES } = require('./src/botAudio'); const { startTunnel, stopTunnel, getTunnelStatus, registerProcessCleanup } = require('./src/tunnel'); const { startDdns, getDdnsStatus, triggerDdnsNow } = require('./src/ddns'); const { initFcm, setFcmAdminEnabled } = require('./src/fcm'); const threadNotify = require('./src/threadNotify'); const { takeSilent } = require('./src/silentMessages'); const app = express(); const BOT_AUDIO_DIR = path.join(UPLOADS_DIR, 'bot-audio'); fs.mkdirSync(BOT_AUDIO_DIR, { recursive: true }); let botAudioManager = null; let socketRuntime = null; // Values set up further down this file (the socket server, the database, // bot audio) or reassigned while running. Route modules in src/routes read // them through this object at the moment they use them, so they always // see the current one. const late = { get io() { return io; }, get botAudioManager() { return botAudioManager; }, get socketRuntime() { return socketRuntime; }, }; const UPLOAD_PATH_RE = /\/uploads\/((?!(?:bot-audio|deleted-attachments|stickers)\/)(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+)/g; const UPLOAD_URL_PATH_RE = /\/uploads\/+([-A-Za-z0-9_.~%/\\]+)/gi; function isSafeUploadRelPath(relPath) { if (typeof relPath !== 'string' || !relPath) return false; if (!/^((?!\.\.)(?!\.\/)(?!\/)[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(relPath)) return false; const parts = relPath.split('/'); if (parts.some(p => !p || p === '.' || p === '..')) return false; return true; } function moveUploadToDeleted(relPath, srcRoot = UPLOADS_DIR) { if (!isSafeUploadRelPath(relPath)) return; const src = path.join(srcRoot, relPath); if (!fs.existsSync(src)) return; let stat; try { stat = fs.statSync(src); } catch { return; } if (!stat.isFile()) return; const dst = path.join(DELETED_ATTACHMENTS_DIR, relPath); try { fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.renameSync(src, dst); // A rename keeps the upload's own timestamp. The retention window counts // from the deletion, so stamp the file now. try { const now = new Date(); fs.utimesSync(dst, now, now); } catch { /* purge falls back to the upload time */ } } catch (err) { // Already moved by another path is fine; anything else leaves the file // live in uploads/ after its message was deleted, so say so. if (err.code !== 'ENOENT') console.warn(`[uploads] Could not move ${relPath} to deleted-attachments:`, err.message); } } // For files an admin chose not to keep: gone for good, never held for recovery. function removeUpload(relPath) { if (!isSafeUploadRelPath(relPath)) return; try { fs.unlinkSync(path.join(UPLOADS_DIR, relPath)); } catch (err) { if (err.code !== 'ENOENT') console.warn(`[uploads] Could not remove ${relPath}:`, err.message); } } function collectUploadRelPaths(contents) { const paths = new Set(); for (const content of contents) { if (typeof content !== 'string' || !content) continue; UPLOAD_URL_PATH_RE.lastIndex = 0; let match; while ((match = UPLOAD_URL_PATH_RE.exec(content)) !== null) { let decoded; try { decoded = decodeURIComponent(match[1]); } catch { continue; } const parts = []; let escapesRoot = false; const segments = decoded.split(process.platform === 'win32' ? /[\\/]+/ : /\/+/); for (const segment of segments) { if (!segment || segment === '.') continue; if (segment === '..') { if (parts.length === 0) { escapesRoot = true; break; } parts.pop(); } else { parts.push(segment); } } if (escapesRoot) continue; const relPath = parts.join('/'); if (/^(?:bot-audio|deleted-attachments|stickers)\//i.test(relPath)) continue; if (isSafeUploadRelPath(relPath)) paths.add(relPath); } } return paths; } function relocateUnreferencedUploads(db, relPaths) { const candidates = new Set( Array.from(relPaths).filter(relPath => fs.existsSync(path.join(UPLOADS_DIR, relPath))) ); if (candidates.size === 0) return; const survivingMessages = db.prepare(` SELECT content, persona_avatar, webhook_avatar FROM messages WHERE content LIKE '%/uploads/%' OR persona_avatar IS NOT NULL OR webhook_avatar IS NOT NULL `).iterate(); for (const message of survivingMessages) { for (const relPath of collectUploadRelPaths([ message.content, message.persona_avatar, message.webhook_avatar ])) { candidates.delete(relPath); } if (candidates.size === 0) return; } const protectedUrlReferences = db.prepare(` SELECT avatar AS reference FROM users WHERE avatar LIKE '%/uploads/%' UNION ALL SELECT border FROM users WHERE border LIKE '%/uploads/%' UNION ALL SELECT avatar FROM user_personas WHERE avatar LIKE '%/uploads/%' UNION ALL SELECT avatar_url FROM webhooks WHERE avatar_url LIKE '%/uploads/%' UNION ALL SELECT icon FROM roles WHERE icon LIKE '%/uploads/%' UNION ALL SELECT value FROM server_settings WHERE value LIKE '%/uploads/%' `).iterate(); for (const row of protectedUrlReferences) { for (const relPath of collectUploadRelPaths([row.reference])) candidates.delete(relPath); if (candidates.size === 0) return; } const findOwnership = db.prepare( 'SELECT user_id, scope, created_at FROM upload_ownership WHERE rel_path = ?' ); const latestDmMessageByUser = new Map(db.prepare(` SELECT m.user_id, MAX(COALESCE(m.edited_at, m.created_at)) AS referenced_at FROM messages m JOIN channels c ON c.id = m.channel_id WHERE c.is_dm = 1 AND m.user_id IS NOT NULL GROUP BY m.user_id `).all().map(row => [row.user_id, row.referenced_at])); const findProtectedFilenameReference = db.prepare(` SELECT 1 WHERE EXISTS(SELECT 1 FROM custom_sounds WHERE filename = ?) OR EXISTS(SELECT 1 FROM custom_emojis WHERE filename = ?) OR EXISTS(SELECT 1 FROM stickers WHERE filename = ?) `); for (const relPath of candidates) { const ownership = findOwnership.get(relPath); // Legacy/unattributed files and private/profile uploads cannot be proven // orphaned, so leave them in place. A channel upload is also retained if // its owner later sent an encrypted DM that could contain a reference. if (!ownership || ownership.scope !== 'channel') continue; const latestDmMessage = latestDmMessageByUser.get(ownership.user_id); if (latestDmMessage && latestDmMessage >= ownership.created_at) continue; if (findProtectedFilenameReference.get(relPath, relPath, relPath)) continue; moveUploadToDeleted(relPath); } } // ── Per-member upload accounting (#5521) ───────────────── // Admins could see the total size of uploads/ but never who filled it, so one // person quietly using the server as personal cloud storage was invisible // unless you went and read the directory yourself. DM attachments made that // worse: the file bytes are encrypted client-side and the message that links // them is E2E ciphertext, so nothing the server can read connects a private // upload to the person who made it. Recording the owner at the moment of // upload is the only place that link still exists. function recordUploadOwnership(userId, relPath, bytes, scope = 'channel') { if (!Number.isInteger(userId) || !isSafeUploadRelPath(relPath)) return; try { const { getDb } = require('./src/database'); getDb().prepare( 'INSERT OR REPLACE INTO upload_ownership (rel_path, user_id, bytes, scope) VALUES (?, ?, ?, ?)' ).run(relPath, userId, Number.isFinite(bytes) ? Math.max(0, Math.round(bytes)) : 0, ['channel', 'dm', 'profile'].includes(scope) ? scope : 'channel'); } catch (err) { // Accounting is a reporting nicety; never fail a working upload over it. console.warn('[uploads] ownership record failed:', err.message); } } // The uploader's chosen scope only ever narrows what we already know from the // endpoint, so a client that lies about it can shift its own bytes between the // public and private columns of its own row. It cannot move them onto someone // else, and the total (the number that matters here) is unaffected. function uploadScopeFromRequest(req, fallback = 'channel') { const raw = typeof req.body?.scope === 'string' ? req.body.scope.trim().toLowerCase() : ''; return ['channel', 'dm', 'profile'].includes(raw) ? raw : fallback; } // Walk the live uploads tree once and total it per owner. Reading sizes from // disk rather than trusting the stored byte count means a deleted, purged, or // moved-to-deleted-attachments file drops out on its own, with no delete hook to // keep in sync, and no drift between the report and reality. let _uploadUsageCache = null; function getUploadUsage() { if (_uploadUsageCache && Date.now() - _uploadUsageCache.at < 60_000) return _uploadUsageCache.data; const sizes = new Map(); // relPath → bytes const walk = (dir, rel) => { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { // Deleted attachments and temporary bot audio are not live member storage. if (!rel && ['bot-audio', 'deleted-attachments'].includes(entry.name)) continue; const sub = rel ? `${rel}/${entry.name}` : entry.name; const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full, sub); continue; } try { sizes.set(sub, fs.statSync(full).size); } catch { /* vanished mid-walk */ } } }; walk(UPLOADS_DIR, ''); const byUser = new Map(); // userId → { total, channel, dm, profile, files } let attributedBytes = 0; try { const { getDb } = require('./src/database'); const rows = getDb().prepare('SELECT rel_path, user_id, scope FROM upload_ownership').all(); for (const row of rows) { if (row.user_id === null) continue; const bytes = sizes.get(row.rel_path); if (bytes === undefined) continue; // gone from disk, so stop counting it let entry = byUser.get(row.user_id); if (!entry) { entry = { total: 0, channel: 0, dm: 0, profile: 0, files: 0 }; byUser.set(row.user_id, entry); } entry.total += bytes; entry[['channel', 'dm', 'profile'].includes(row.scope) ? row.scope : 'channel'] += bytes; entry.files++; attributedBytes += bytes; } } catch (err) { // Every file then shows as unattributed, so the report is wrong; say why. console.warn('[uploads] Could not read upload ownership for the usage report:', err.message); } let liveBytes = 0; for (const bytes of sizes.values()) liveBytes += bytes; // Uploads made before this shipped have no owner row, so they land here // rather than being silently spread across members who did not make them. const data = { byUser, liveBytes, attributedBytes, unattributedBytes: Math.max(0, liveBytes - attributedBytes), fileCount: sizes.size }; _uploadUsageCache = { at: Date.now(), data }; return data; } // Trust proxy configuration: whose X-Forwarded-For to believe when reading // the real client IP. src/clientIp.js holds the rule, so HTTP and sockets // agree. // // (unset) default: a proxy on this machine only (inside a container, // also the container network's private ranges). Anything // else ignores the header, so nobody can spoof their IP // past the auth rate limiter or an IP ban. // TRUST_PROXY=192.168.1.20 a reverse proxy on another LAN machine // TRUST_PROXY=1 one hop, wherever it is (Cloudflare's proxy, or any proxy // on another machine) // TRUST_PROXY=2 two hops, etc.; TRUST_PROXY=0 trusts nothing // // Without a trusted proxy every user behind one shares its IP in the auth // rate limiter, so innocent users would hit the limit on their first login. const { trustProxySetting, compileTrust, normalizeIp } = require('./src/clientIp'); app.set('trust proxy', trustProxySetting()); // A proxy Haven does not trust shows up as every visitor having the proxy's // address. Say so once per address, with the setting that fixes it, rather // than leave the owner to puzzle over shared rate limits. { const trusted = compileTrust(trustProxySetting()); const warned = new Set(); app.use((req, res, next) => { if (warned.size < 20 && req.headers['x-forwarded-for']) { const peer = normalizeIp(req.socket?.remoteAddress || ''); if (peer && !warned.has(peer) && !trusted(peer, 0)) { warned.add(peer); console.warn(`[proxy] ${peer} sent X-Forwarded-For, but Haven does not trust it as a proxy, so everyone coming through it shares that address. If ${peer} is your reverse proxy, set TRUST_PROXY=${peer} in .env and restart.`); } } next(); }); } // ── IP ban gate (v3.20.0) ───────────────────────────────── // Run before anything else (parsers, helmet, static) so banned addresses // can't consume server resources. Cached for 30s so we aren't hitting SQLite // on every static asset request from a normal page load. Cache is invalidated // from the moderation socket handlers whenever the table changes. // Entries are split into exact addresses (fast Set lookup, the common case) // and CIDR ranges (linear scan, expected to stay small). Both sides are run // through normalizeIp so a ban written as "1.2.3.4" also stops the socket // path, which sees "::ffff:1.2.3.4" on a dual-stack listener. Before v3.42.0 // those two never compared equal and bans silently only half-applied. const _clientIp = require('./src/clientIp'); let _ipBanCache = { set: new Set(), cidrs: [], expires: 0 }; function _refreshIpBanCache() { try { const { getDb } = require('./src/database'); const rows = getDb().prepare('SELECT ip FROM ip_bans').all(); const set = new Set(), cidrs = []; for (const r of rows) { if (!r.ip) continue; if (r.ip.includes('/')) cidrs.push(r.ip); else set.add(_clientIp.normalizeIp(r.ip)); } _ipBanCache = { set, cidrs, expires: Date.now() + 30000 }; } catch (err) { // Keep the bans already known rather than dropping them all: an empty // list here would let every banned address back in until the next read. // Try again in a few seconds. console.warn('[ip-bans] Could not read the ban list, keeping the last one:', err.message); _ipBanCache = { ..._ipBanCache, expires: Date.now() + 5000 }; } } function invalidateIpBanCache() { _ipBanCache.expires = 0; } function isIpBanned(ip) { if (!ip) return false; if (Date.now() > _ipBanCache.expires) _refreshIpBanCache(); const norm = _clientIp.normalizeIp(ip); if (!norm) return false; // This machine always gets in, so whoever runs the server can lift a ban // that went too far, even one placed before ban-ip refused them. if (norm === '127.0.0.1' || norm === '::1') return false; if (_ipBanCache.set.has(norm)) return true; return _ipBanCache.cidrs.some(c => _clientIp.ipMatches(norm, c)); } app.use((req, res, next) => { if (isIpBanned(req.ip)) { return res.status(403).type('text/plain').send('Your IP has been banned from this server.'); } next(); }); // Expose the invalidator on the app so socket handlers can poke it. app.set('invalidateIpBanCache', invalidateIpBanCache); app.set('isIpBanned', isIpBanned); // ── Helper: verify admin from DB (don't trust JWT claims alone) ───── // JWT isAdmin may be stale if admin was demoted since token was issued. // Upload cap for one user: the server setting, or a higher one from a role // they hold (roles.max_upload_mb). Admins are checked against the setting. function uploadCapMb(user) { const db = require('./src/database').getDb(); const base = parseInt(db.prepare("SELECT value FROM server_settings WHERE key = 'max_upload_mb'").get()?.value, 10) || 25; // The admin too: a server owner who lowers the setting for everyone and // gives their own role a higher cap was held to the setting. try { const row = db.prepare(` SELECT MAX(r.max_upload_mb) AS cap FROM roles r JOIN user_roles ur ON ur.role_id = r.id WHERE ur.user_id = ? AND ur.channel_id IS NULL AND r.max_upload_mb IS NOT NULL `).get(user.id); return Math.max(base, parseInt(row?.cap, 10) || 0); } catch { return base; } } function verifyAdminFromDb(user) { if (!user) return false; try { const { getDb } = require('./src/database'); const row = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(user.id); return !!(row && row.is_admin); } catch { return false; } } // The permission check the rest of Haven uses (roles, the level thresholds // and permissions set on one person), made once the database is open. let _permissions = null; function permissionsModule() { if (!_permissions) _permissions = require('./src/socketHandlers/permissions')(require('./src/database').getDb()); return _permissions; } // For the HTTP routes. It used to look only at roles, so someone given a // permission through the level thresholds or on their own (which the app shows // as granted) was refused here, uploads included, and someone denied one on // their own was still let through by a role. function userHasPermission(userId, permission) { if (!userId) return false; try { const { getDb } = require('./src/database'); const db = getDb(); const isAdmin = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId); if (isAdmin && isAdmin.is_admin) return true; // A server-wide setting on the person decides first, allow or deny. The // table is created at startup; if this read fails, the outer catch denies // rather than skipping a personal deny and letting a role allow it. const own = db.prepare(` SELECT allowed FROM user_role_perms WHERE user_id = ? AND permission = ? AND channel_id IS NULL ORDER BY allowed ASC LIMIT 1 `).get(userId, permission); if (own) return own.allowed === 1; if (permissionsModule().userHasPermission(userId, permission)) return true; // These routes aren't tied to one channel, so a role held in any channel // still counts, as it always has here. const row = db.prepare(` SELECT 1 FROM role_permissions rp JOIN roles r ON rp.role_id = r.id JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = ? AND rp.permission = ? AND rp.allowed = 1 LIMIT 1 `).get(userId, permission); return !!row; } catch { return false; } } // ── Referrer-Policy (admin-configurable) ───────────────── // The Referrer-Policy header is sent on every response by the security-headers // middleware below. Admins can change it from Settings → Security; the value is // cached in memory (loaded at boot, refreshed when it changes) so we never read // the DB per request. Default matches the value helmet used to set. // // Two of the eight standard policies are deliberately NOT offered: 'unsafe-url' // (sends the full URL to every site, always) and 'no-referrer-when-downgrade' // (sends the full URL to any cross-origin HTTPS site). Haven puts secrets in // the query string (invite links arrive as ?invite=CODE and deep links as // ?channel=CODE&message=ID), and they are only scrubbed by replaceState once // the socket connects. Under either policy, an externally hosted image in the // channel would carry that invite code to its host in the Referer header on // first paint. The six kept here all stop at the origin cross-origin, which is // enough for the case this setting exists for (CDNs like X/Twitter that reject // a cross-origin referrer on video). Anything not in this list falls back to // the default below, so a value saved before this list was narrowed degrades // safely instead of persisting. // Keep in sync with the validation list in src/socketHandlers/admin.js. const VALID_REFERRER_POLICIES = ['no-referrer', 'origin', 'origin-when-cross-origin', 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin']; // Default is 'same-origin' as of 3.41.0, up from the 'strict-origin-when-cross-origin' // helmet used to set. Sending the origin cross-origin is enough for X's video CDN to // return 403, so Twitter/X embeds showed a thumbnail and a dead play button on every // Haven server out of the box. 'same-origin' sends nothing cross-origin, which fixes // that and shares strictly less than before. Admins who need the old behaviour (a host // that uses the referrer for hotlink protection) can pick it in Settings → Security. const DEFAULT_REFERRER_POLICY = 'same-origin'; let currentReferrerPolicy = DEFAULT_REFERRER_POLICY; // ── Security Headers (helmet) ──────────────────────────── // ── Do we serve TLS ourselves? ─────────────────────────── // Resolved here because the security headers below depend on the answer. On // plain HTTP, telling a browser to upgrade every request to HTTPS breaks the // page rather than protecting it: the CSS and JS are re-requested over https on // a port with no TLS listener, so a remote visitor gets an unstyled page with // dead buttons. It looks perfect to whoever is testing on localhost, which // browsers treat as trustworthy and never upgrade. A Windows install whose SSL // step was skipped (no OpenSSL on PATH) lands in exactly that state without // anyone setting FORCE_HTTP. let sslCert = process.env.SSL_CERT_PATH; let sslKey = process.env.SSL_KEY_PATH; const forceHttp = (process.env.FORCE_HTTP || '').toLowerCase() === 'true'; // If not explicitly configured, use the certs in the data directory, and make // them ourselves when they are missing. The startup scripts used to need an // openssl.exe for this, which Windows does not ship (OpenSSH is not OpenSSL), // so those machines silently fell back to HTTP. if (!sslCert && !sslKey) { const autoCert = path.join(CERTS_DIR, 'cert.pem'); const autoKey = path.join(CERTS_DIR, 'key.pem'); if (!forceHttp && !(fs.existsSync(autoCert) && fs.existsSync(autoKey))) { try { const made = require('./src/selfsignedCert').ensureCerts(CERTS_DIR); console.log(`🔒 Generated a self-signed certificate in ${CERTS_DIR} (${made.names.join(', ')})`); } catch (err) { console.warn('⚠️ Could not generate a self-signed certificate:', err.message); } } if (fs.existsSync(autoCert) && fs.existsSync(autoKey)) { sslCert = autoCert; sslKey = autoKey; } } else { // Resolve relative paths against the data directory if (sslCert && !path.isAbsolute(sslCert)) sslCert = path.resolve(DATA_DIR, sslCert); if (sslKey && !path.isAbsolute(sslKey)) sslKey = path.resolve(DATA_DIR, sslKey); } const useSSL = !!(sslCert && sslKey) && !forceHttp; let tlsIssue = null; app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'", "blob:", "https://www.youtube.com", "https://w.soundcloud.com", "https://challenges.cloudflare.com"], // last host: opt-in Turnstile CAPTCHA on registration styleSrc: ["'self'", "'unsafe-inline'"], // inline styles (fonts are self-hosted, no third-party CDN) imgSrc: ["'self'", "data:", "blob:", "https:", "http:"], // link preview OG images + GIPHY (http: for local/self-hosted services) connectSrc: ["'self'", "ws:", "wss:", "https:"], // Socket.IO + cross-origin health checks mediaSrc: ["'self'", "blob:", "data:", "https:", "http:"], // WebRTC audio + notification sounds + link preview video embeds fontSrc: ["'self'"], // self-hosted fonts only (see /public/fonts) workerSrc: ["'self'", "blob:"], // service worker + Ruffle WebAssembly workers objectSrc: ["'none'"], frameSrc: ["'self'", "https://open.spotify.com", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://w.soundcloud.com", "https://challenges.cloudflare.com"], // Listen Together embeds + game iframes + Turnstile widget baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'self'"], // allow mobile app iframe, block third-party clickjacking ...(useSSL ? {} : { upgradeInsecureRequests: null }), // helmet 8.x auto-appends upgrade-insecure-requests; it breaks every page when Haven is not serving TLS } }, crossOriginEmbedderPolicy: false, // needed for WebRTC crossOriginOpenerPolicy: false, // needed for WebRTC hsts: useSSL ? { maxAge: 31536000, includeSubDomains: false } : false, // force HTTPS for 1 year (only sent when we actually serve it) referrerPolicy: false, // set dynamically from the admin-configurable cache in the middleware below })); // Additional security headers helmet doesn't cover app.use((req, res, next) => { res.setHeader('Permissions-Policy', 'camera=(self), microphone=(self), geolocation=(), payment=()'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', currentReferrerPolicy); // admin-configurable (Settings → Security) next(); }); // Disable Express version disclosure app.disable('x-powered-by'); // ── Body Parsing with size limits ──────────────────────── // Global limit bumped to 128kb so legit large-but-bounded payloads like the // per-user saved server list (PUT /api/auth/user-servers, ~40kb at 100+ // servers) aren't rejected by the global parser before per-route parsers // can apply their own limits. Individual routes still set tighter limits // where appropriate. (#5347 v3.15.7) app.use(express.json({ limit: '128kb' })); app.use(express.urlencoded({ extended: false, limit: '128kb' })); // ── Self-hosted fonts (long-lived cache) ───────────────── // Fonts never change for a given filename, so let clients cache them for a // year and skip revalidation. A ?v= bump in style.css busts the cache when a // file is ever replaced. Mounted before the general /public handler so these // win over its always-revalidate (maxAge:0) policy. app.use('/fonts', express.static(path.join(__dirname, 'public', 'fonts'), { dotfiles: 'deny', maxAge: '1y', immutable: true, })); // ── Flash player (Ruffle) ──────────────────────────────── // Served from the npm package pinned in package.json. It used to come from // unpkg, which meant whatever version was newest that day, fetched from a // third party by every player, and a CSP that let the games pages run any // script published to npm. The core scripts and .wasm files carry a content // hash in their names, so those can be cached for good. let RUFFLE_DIR = null; try { RUFFLE_DIR = path.dirname(require.resolve('@ruffle-rs/ruffle/package.json')); } catch { console.warn('Flash games are unavailable: the Flash player package is missing. Run npm install in the Haven folder.'); } if (RUFFLE_DIR) { app.use('/games/ruffle', express.static(RUFFLE_DIR, { dotfiles: 'deny', maxAge: 0, setHeaders: (res, file) => { if (/(^|\.)[0-9a-f]{20}\.(js|wasm)$/.test(path.basename(file))) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } }, })); } // Flash games downloaded from Activities, kept in the data folder. Games // installed before that sat in public/games/roms, which the static files // below still serve. app.use('/games/roms', express.static(GAMES_ROMS_DIR, { dotfiles: 'deny', maxAge: 0 })); // ── Static files with caching ──────────────────────────── app.use(express.static(path.join(__dirname, 'public'), { dotfiles: 'deny', // block .env, .git, etc. etag: true, // ETag for conditional requests lastModified: true, // Last-Modified header maxAge: 0, // always revalidate (prevents stale JS/CSS after deploys) })); // ── Block access to internal upload folders ───────────── // Files moved into deleted-attachments are no longer part of any message and // must stop being reachable, which is the entire point of moving them. // // A 404 mounted at the prefix does not achieve that. Express matches the mount // against the raw path while express.static decodes before it resolves, so // three shapes walked straight past the guard and served the file: // /uploads/deleted%2Dattachments/x, /uploads//deleted-attachments/x, and // /uploads/deleted-attachments%2Fx. Anyone who saw an attachment before it was // deleted knows its filename, so deletion was not actually revoking access. // // Decode the path, resolve it against the uploads root, and check containment, // so it is the real target on disk being judged rather than the spelling of // the URL. Compared case-insensitively because NTFS is. const UPLOAD_MEDIA_EXTS = new Set([ '.mp3', '.ogg', '.oga', '.wav', '.m4a', '.aac', '.flac', '.opus', '.weba', '.mp4', '.webm', '.mov', '.m4v', '.ogv', ]); const BLOCKED_UPLOAD_DIRS = ['deleted-attachments', 'bot-audio'].map( dir => path.resolve(UPLOADS_DIR, dir).toLowerCase() ); app.use('/uploads', (req, res, next) => { let decoded; try { decoded = decodeURIComponent(req.path); } catch { return res.status(400).end(); } // Windows also answers to a folder's 8.3 short name (DELETE~1 for // deleted-attachments), and ignores a trailing dot or space and anything // after a colon (alternate streams). None of those appear in a real upload // name, so a path with any of them is refused before it can slip past. if (decoded.split(/[\\/]/).some(seg => seg.includes('~') || seg.includes(':') || /[. ]$/.test(seg))) { return res.status(404).end(); } // path.resolve treats a backslash as a separator on Windows and as an // ordinary filename character on Linux, which is exactly right in both // cases, so the raw decoded path goes in as-is. const target = path.resolve(UPLOADS_DIR, '.' + decoded).toLowerCase(); for (const blocked of BLOCKED_UPLOAD_DIRS) { if (target === blocked || target.startsWith(blocked + path.sep)) return res.status(404).end(); } return next(); }); // ── Serve uploads from external data directory ────────── app.use('/uploads', express.static(UPLOADS_DIR, { dotfiles: 'deny', maxAge: '7d', // 7 days: avatars & images rarely change; filenames include timestamps for uniqueness immutable: true, // tells browser the file at this URL will never change (cache-busting via new filename) etag: true, lastModified: true, setHeaders: (res, filePath) => { // Force download for non-image files (prevents HTML/SVG execution in browser) const ext = path.extname(filePath).toLowerCase(); if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) { // Allow cross-origin access for images (needed for server icon pulling). // CORP override is required because helmet defaults to 'same-origin', which // would otherwise block cross-origin loads even with ACAO set. // Vary: Origin prevents a non-CORS cached response from being reused for a // CORS request (which is what causes the "No 'Access-Control-Allow-Origin' // header is present" error on a cached image). res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Vary', 'Origin'); } else if (ext === '.svg') { // SVG (issue #5309): renderable inline via tag (browsers run SVG in // "secure static mode": no scripts, no XHR), but direct navigation still // gets attachment-disposition so opening the raw URL in a new tab can't // execute the file. CSP doubles up on that: even if a future browser // change allowed any external loads inside -rendered SVG, this // header forbids everything except inline styles (needed for fill/stroke). res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Vary', 'Origin'); res.setHeader('Content-Disposition', 'attachment'); res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"); } else { res.setHeader('Content-Disposition', 'attachment'); // Anyone can upload a file, and one served as JavaScript (or HTML, CSS, // XML) from Haven's own origin counts as 'self' in the CSP: a