Add opt-in trusted-proxy client resolution, update docs
Build, Scan & Publish Image / build-scan-push (release) Successful in 12m30s

This commit is contained in:
mdaleo404 committed 2026-10-09 07:29:53 +01:00
1 parent 4cc45cafc2
commit 23e1527b82
4 files changed
+159 -9

No files matched your search

+19 -7
View File
@@ -1,4 +1,5 @@
import http from 'node:http';
import net from 'node:net';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
@@ -27,6 +28,7 @@ const TIME_ZONE = resolveTimeZone(process.env.TZ);
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_COVER_BYTES = 4 * 1024 * 1024;
const TRUST_PROXY_HOPS = Number.parseInt(process.env.TRUST_PROXY || '0', 10) || 0;
const HEADERS_TIMEOUT_MS = 60_000;
const REQUEST_TIMEOUT_MS = 300_000;
const KEEP_ALIVE_TIMEOUT_MS = 5_000;
@@ -103,13 +105,23 @@ function routeParam(pathname, pattern) {
function requireWebCsrf(req) {
if (req.headers['x-kovi-csrf'] !== WEB_CSRF) throw Object.assign(new Error('Missing browser request token.'), {status:403});
}
// Resolve the effective client address. By default the direct peer wins.
// TRUST_PROXY opts in to trusting that many reverse-proxy hops and reads the
// client address from X-Forwarded-For, validated as a literal IP address.
function clientAddress(req) {
const direct=req.socket.remoteAddress || null;
if(TRUST_PROXY_HOPS<1) return direct;
const chain=String(req.headers['x-forwarded-for']||'').split(',').map(x=>x.trim()).filter(Boolean);
const candidate=chain[chain.length-TRUST_PROXY_HOPS];
return candidate && net.isIP(candidate) ? candidate : direct;
}
function prunePairAttempts(now) {
for (const [key,row] of pairAttempts) {
if (now-row.start > PAIR_WINDOW_MS) pairAttempts.delete(key);
}
}
function checkPairRate(req) {
const key=req.socket.remoteAddress;
const key=clientAddress(req);
if(!key) return;
const now=Date.now();
const row=pairAttempts.get(key) || {start:now,count:0};
@@ -137,7 +149,7 @@ async function handleApi(req,res,url) {
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:clientAddress(req) || 'unknown',userAgent:cleanText(req.headers['user-agent'],120) || 'none'});
}
return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders());
}
@@ -197,10 +209,10 @@ async function handleApi(req,res,url) {
const body=await readJson(req,32*1024);
const paired=consumePairingCode(db,body.code,{deviceId:body.device_id,model:cleanText(body.model,120),version:cleanText(body.version,40)});
if(!paired) {
warn('pairing.rejected','Pairing attempt rejected because the code was invalid, expired, or already used.',{remote:req.socket.remoteAddress || 'unknown',deviceId:cleanText(body.device_id,128),model:cleanText(body.model,120)});
warn('pairing.rejected','Pairing attempt rejected because the code was invalid, expired, or already used.',{remote:clientAddress(req) || 'unknown',deviceId:cleanText(body.device_id,128),model:cleanText(body.model,120)});
return json(res,401,{error:'Pairing code is invalid, expired, or already used.'},securityHeaders());
}
info('device.paired','KOReader paired successfully.',{deviceId:paired.deviceId,model:cleanText(body.model,120),pluginVersion:cleanText(body.version,40),remote:req.socket.remoteAddress || 'unknown'});
info('device.paired','KOReader paired successfully.',{deviceId:paired.deviceId,model:cleanText(body.model,120),pluginVersion:cleanText(body.version,40),remote:clientAddress(req) || 'unknown'});
if(!versionAtLeast(body.version,MIN_COVER_PLUGIN_VERSION)) warn('plugin.outdated','Paired KOReader plugin is too old for embedded-cover and ISBN sync.',{deviceId:paired.deviceId,pluginVersion:cleanText(body.version,40),minimumVersion:MIN_COVER_PLUGIN_VERSION,latestVersion:LATEST_PLUGIN_VERSION});
return json(res,200,{message:'KOReader paired with kovi.',token:paired.token,device_id:paired.deviceId,latest_plugin_version:LATEST_PLUGIN_VERSION,update_recommended:!versionAtLeast(body.version,LATEST_PLUGIN_VERSION)},securityHeaders());
}
@@ -208,7 +220,7 @@ async function handleApi(req,res,url) {
if(req.method==='POST' && url.pathname==='/api/plugin/import') {
const device=authenticateDevice(db,req.headers.authorization);
if(!device) {
warn('plugin.sync.rejected','Rejected plugin sync with missing or invalid device token.',{remote:req.socket.remoteAddress || 'unknown'});
warn('plugin.sync.rejected','Rejected plugin sync with missing or invalid device token.',{remote:clientAddress(req) || 'unknown'});
return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders());
}
const body=await readJson(req);
@@ -236,7 +248,7 @@ async function handleApi(req,res,url) {
if(req.method==='POST' && url.pathname==='/api/plugin/cover') {
const device=authenticateDevice(db,req.headers.authorization);
if(!device) {
warn('cover.embedded.rejected','Rejected embedded cover upload with missing or invalid device token.',{remote:req.socket.remoteAddress || 'unknown'});
warn('cover.embedded.rejected','Rejected embedded cover upload with missing or invalid device token.',{remote:clientAddress(req) || 'unknown'});
return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders());
}
const md5=cleanText(req.headers['x-kovi-book-md5'],128);
@@ -266,7 +278,7 @@ async function handleApi(req,res,url) {
cb(null,chunk);
}
});
info('import.sqlite.start','Manual KOReader database upload started.',{filename,contentLength:contentLength||undefined,remote:req.socket.remoteAddress || 'unknown'});
info('import.sqlite.start','Manual KOReader database upload started.',{filename,contentLength:contentLength||undefined,remote:clientAddress(req) || 'unknown'});
try {
await pipeline(req,limiter,fs.createWriteStream(tempPath,{flags:'wx',mode:0o600}));
if(size<16 || first.toString('ascii',0,16)!=='SQLite format 3\u0000') throw Object.assign(new Error('The selected file is not a SQLite database.'),{status:400});