Harden the server and add a Links page

Security headers, cross-site write refusal, atomic JSON writes, admin backup download, /healthz and Docker health check, OnlyOffice requires a JWT secret, npm test, CI audit, arm64 hub image. New Links tab for personal bookmarks, saved with per-person settings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:43:34 -04:00
1 parent b0d3aec811
commit 1f8c9493b7
16 files changed
+147 -25

No files matched your search

+30 -5
View File
@@ -2,6 +2,7 @@
// (Seafile sends no CORS headers, so the browser talks to /sf/* instead).
const http = require('http');
const fs = require('fs');
const atomicWrite = require('./atomic');
const crypto = require('crypto');
const path = require('path');
const { Readable } = require('stream');
@@ -35,6 +36,7 @@ const settings = require('./settings');
const search = require('./search');
const users = require('./users');
const admin = require('./admin');
const backup = require('./backup');
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
const vault = require('./vault')('nehub-plex');
@@ -198,7 +200,7 @@ const PLEX_HEADERS = {
// Each person's Plex sign-in is stored (encrypted) in their own folder, so it follows them to every device.
const plexFile = () => require('path').join(users.dir(), 'plex.json');
const plexLoad = () => { try { return vault.unseal(JSON.parse(require('fs').readFileSync(plexFile(), 'utf8')).data) || {}; } catch { return {}; } };
const plexSave = o => require('fs').writeFileSync(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const plexSave = o => atomicWrite(plexFile(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
const plexWipe = () => { try { require('fs').unlinkSync(plexFile()); } catch {} };
const PLEX_TV = process.env.PLEX_TV_URL || 'https://plex.tv'; // (only the tests change this)
const plexTv = async (path, token, opts = {}) => {
@@ -438,7 +440,8 @@ async function ddgAnswer(req, res) {
} catch { json(res, 200, {}); } // a failed lookup must never break Search
}
if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing only works if the Document Server has JWT disabled, and save callbacks are unauthenticated. Testing only.');
if (!JWT_SECRET) console.warn('WARNING: ONLYOFFICE_JWT_SECRET not set. In-app editing is switched off (the save callback would be unauthenticated). Set it, or ALLOW_UNSIGNED_OO=1 for testing only.');
const OO_OK = !!JWT_SECRET || process.env.ALLOW_UNSIGNED_OO === '1';
// Which tab(s) each part of the server belongs to. A person needs at least one of them (admins have all).
const ACCESS = [
@@ -455,8 +458,29 @@ const ACCESS = [
];
const forbidden = (res, msg = 'You do not have access to this') => { res.writeHead(403, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: msg })); };
// Headers for every response. No script/style rules on purpose (the app uses inline handlers); these stop framing by other sites,
// content sniffing, form hijacking and leaking the address to other sites.
function securityHeaders(res) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()');
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
}
/** Cross-site request check: a browser request that changes something must come from this same site. */
function crossSite(req) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return false;
const sfs = req.headers['sec-fetch-site'];
if (sfs) return !['same-origin', 'none'].includes(sfs);
const o = req.headers.origin; if (!o) return false; // not a browser form/fetch (scripts, tests, OnlyOffice)
try { const h = new URL(o).host; return ![req.headers['x-forwarded-host'], req.headers.host, PUBLIC_URL && new URL(PUBLIC_URL).host].includes(h); } catch { return true; }
}
async function handleRequest(req, res) {
const pathname = new URL(req.url, 'http://x').pathname;
securityHeaders(res);
if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); }
if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused');
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session
// Everything else needs a passkey session, except the sign-in page and its assets,
@@ -466,7 +490,7 @@ async function handleRequest(req, res) {
const open = ['/login', '/login.js', '/webauthn.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/sw.js', '/api/oo/callback'];
if (!user) {
if (open.includes(pathname) || pathname.startsWith('/icons/')) {
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return ooCallback(req, res);
if (req.method === 'POST' && req.url.startsWith('/api/oo/callback')) return OO_OK ? ooCallback(req, res) : json(res, 403, { error: 1 });
return serveStatic(req, res);
}
if (/^\/(api|sf|plex)\//.test(pathname)) { res.writeHead(401, { 'content-type': 'application/json', 'x-hub-auth': 'required' }); return res.end('{"error":"Sign in required"}'); }
@@ -483,9 +507,10 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res) : forbidden(res, 'Only an admin can do this');
if (pathname === '/api/ddg') return ddgAnswer(req, res);
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
if (pathname === '/api/settings') return settings.handle(req, res);