Harden server request handling

This commit is contained in:
mdaleo404 committed 2026-10-09 07:18:46 +01:00
1 parent 01e8cd9e6a
commit 4cc45cafc2
1 file changed
+36 -3
+36 -3
View File
@@ -27,8 +27,17 @@ const TIME_ZONE = resolveTimeZone(process.env.TZ);
const MAX_FILE_BYTES = Number(process.env.MAX_FILE_SIZE_MB || 100) * 1024 * 1024; const MAX_FILE_BYTES = Number(process.env.MAX_FILE_SIZE_MB || 100) * 1024 * 1024;
const MAX_JSON_BYTES = Number(process.env.MAX_JSON_MB || 20) * 1024 * 1024; const MAX_JSON_BYTES = Number(process.env.MAX_JSON_MB || 20) * 1024 * 1024;
const MAX_COVER_BYTES = 4 * 1024 * 1024; const MAX_COVER_BYTES = 4 * 1024 * 1024;
const HEADERS_TIMEOUT_MS = 60_000;
const REQUEST_TIMEOUT_MS = 300_000;
const KEEP_ALIVE_TIMEOUT_MS = 5_000;
const PAIR_WINDOW_MS = 10 * 60_000;
const PAIR_MAX_ATTEMPTS = 30;
const PAIR_ATTEMPTS_MAX_ENTRIES = 5_000;
const PING_LOG_INTERVAL_MS = 10 * 60_000;
const WEB_CSRF = randomId(24); const WEB_CSRF = randomId(24);
const pairAttempts = new Map(); const pairAttempts = new Map();
let lastPingLogAt = 0;
let backupInFlight = false;
function versionParts(value){return String(value||'0').split('.').map(x=>Number.parseInt(x,10)||0).slice(0,3)} function versionParts(value){return String(value||'0').split('.').map(x=>Number.parseInt(x,10)||0).slice(0,3)}
function versionAtLeast(value,minimum){const a=versionParts(value),b=versionParts(minimum);for(let i=0;i<3;i++){if((a[i]||0)>(b[i]||0))return true;if((a[i]||0)<(b[i]||0))return false}return true} function versionAtLeast(value,minimum){const a=versionParts(value),b=versionParts(minimum);for(let i=0;i<3;i++){if((a[i]||0)>(b[i]||0))return true;if((a[i]||0)<(b[i]||0))return false}return true}
@@ -94,12 +103,23 @@ function routeParam(pathname, pattern) {
function requireWebCsrf(req) { function requireWebCsrf(req) {
if (req.headers['x-kovi-csrf'] !== WEB_CSRF) throw Object.assign(new Error('Missing browser request token.'), {status:403}); if (req.headers['x-kovi-csrf'] !== WEB_CSRF) throw Object.assign(new Error('Missing browser request token.'), {status:403});
} }
function prunePairAttempts(now) {
for (const [key,row] of pairAttempts) {
if (now-row.start > PAIR_WINDOW_MS) pairAttempts.delete(key);
}
}
function checkPairRate(req) { function checkPairRate(req) {
const key=req.socket.remoteAddress || 'unknown', now=Date.now(), windowMs=10*60_000; const key=req.socket.remoteAddress;
if(!key) return;
const now=Date.now();
const row=pairAttempts.get(key) || {start:now,count:0}; const row=pairAttempts.get(key) || {start:now,count:0};
if(now-row.start>windowMs){row.start=now;row.count=0} if(now-row.start>PAIR_WINDOW_MS){row.start=now;row.count=0}
row.count++;pairAttempts.set(key,row); row.count++;pairAttempts.set(key,row);
if(row.count>30) throw Object.assign(new Error('Too many pairing attempts. Try again later.'),{status:429}); if(pairAttempts.size>PAIR_ATTEMPTS_MAX_ENTRIES){
prunePairAttempts(now);
while(pairAttempts.size>PAIR_ATTEMPTS_MAX_ENTRIES) pairAttempts.delete(pairAttempts.keys().next().value);
}
if(row.count>PAIR_MAX_ATTEMPTS) throw Object.assign(new Error('Too many pairing attempts. Try again later.'),{status:429});
} }
function storageStats(){ function storageStats(){
@@ -113,7 +133,12 @@ async function handleApi(req,res,url) {
if(req.method==='GET' && url.pathname==='/api/session') return json(res,200,{csrf:WEB_CSRF},securityHeaders()); if(req.method==='GET' && url.pathname==='/api/session') return json(res,200,{csrf:WEB_CSRF},securityHeaders());
if(req.method==='GET' && url.pathname==='/api/health') return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders()); if(req.method==='GET' && url.pathname==='/api/health') return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders());
if(req.method==='GET' && url.pathname==='/api/plugin/ping') { if(req.method==='GET' && url.pathname==='/api/plugin/ping') {
// Throttle the event so unauthenticated pings cannot grow the log without bound.
const now=Date.now();
if(now-lastPingLogAt>=PING_LOG_INTERVAL_MS){
lastPingLogAt=now;
info('plugin.ping','KOReader ping received.',{remote:req.socket.remoteAddress || 'unknown',userAgent:cleanText(req.headers['user-agent'],120) || 'none'}); info('plugin.ping','KOReader ping received.',{remote:req.socket.remoteAddress || 'unknown',userAgent:cleanText(req.headers['user-agent'],120) || 'none'});
}
return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders()); return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders());
} }
if(req.method==='GET' && url.pathname==='/api/books') return json(res,200,{books:listBooks(db)},securityHeaders()); if(req.method==='GET' && url.pathname==='/api/books') return json(res,200,{books:listBooks(db)},securityHeaders());
@@ -134,9 +159,13 @@ async function handleApi(req,res,url) {
if(req.method==='GET' && url.pathname==='/api/export/books.csv'){sendBooksCsv(res,db,securityHeaders());return true} if(req.method==='GET' && url.pathname==='/api/export/books.csv'){sendBooksCsv(res,db,securityHeaders());return true}
if(req.method==='GET' && url.pathname==='/api/export/highlights.md'){sendHighlightsMarkdown(res,db,securityHeaders());return true} if(req.method==='GET' && url.pathname==='/api/export/highlights.md'){sendHighlightsMarkdown(res,db,securityHeaders());return true}
if(req.method==='GET' && url.pathname==='/api/backup'){ if(req.method==='GET' && url.pathname==='/api/backup'){
if(backupInFlight) return json(res,429,{error:'A backup is already being generated.'},securityHeaders({'Retry-After':'5'}));
backupInFlight=true;
try {
const backupPath=path.join(UPLOAD_DIR,`kovi-backup-${Date.now()}-${randomId(6)}.tar.gz`); const backupPath=path.join(UPLOAD_DIR,`kovi-backup-${Date.now()}-${randomId(6)}.tar.gz`);
await createBackupArchive(db,DATA_DIR,backupPath,{version:VERSION}); await createBackupArchive(db,DATA_DIR,backupPath,{version:VERSION});
try{const st=await fsp.stat(backupPath);res.writeHead(200,securityHeaders({'Content-Type':'application/gzip','Content-Disposition':`attachment; filename="kovi-backup-${new Date().toISOString().slice(0,10)}.tar.gz"`,'Content-Length':st.size,'Cache-Control':'no-store'}));await pipeline(fs.createReadStream(backupPath),res)}finally{await fsp.rm(backupPath,{force:true}).catch(()=>{})} try{const st=await fsp.stat(backupPath);res.writeHead(200,securityHeaders({'Content-Type':'application/gzip','Content-Disposition':`attachment; filename="kovi-backup-${new Date().toISOString().slice(0,10)}.tar.gz"`,'Content-Length':st.size,'Cache-Control':'no-store'}));await pipeline(fs.createReadStream(backupPath),res)}finally{await fsp.rm(backupPath,{force:true}).catch(()=>{})}
} finally { backupInFlight=false; }
return true; return true;
} }
@@ -342,4 +371,8 @@ const server=http.createServer(async(req,res)=>{
} }
}); });
server.headersTimeout=HEADERS_TIMEOUT_MS;
server.requestTimeout=REQUEST_TIMEOUT_MS;
server.keepAliveTimeout=KEEP_ALIVE_TIMEOUT_MS;
server.listen(PORT,HOST,()=>info('startup.ready','kovi is ready.',{version:VERSION,url:`http://${HOST}:${PORT}`,dataDir:DATA_DIR,timeZone:TIME_ZONE,maxUploadMb:Math.round(MAX_FILE_BYTES/1024/1024)})); server.listen(PORT,HOST,()=>info('startup.ready','kovi is ready.',{version:VERSION,url:`http://${HOST}:${PORT}`,dataDir:DATA_DIR,timeZone:TIME_ZONE,maxUploadMb:Math.round(MAX_FILE_BYTES/1024/1024)}));