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:
1 parent
b0d3aec811
commit
1f8c9493b7
16 files changed
+147
-25
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user