Add opt-in trusted-proxy client resolution, update docs
Build, Scan & Publish Image / build-scan-push (release) Successful in 12m30s
Build, Scan & Publish Image / build-scan-push (release) Successful in 12m30s
This commit is contained in:
4 files changed
+159
-9
No files matched your search
@@ -189,6 +189,7 @@ The logger intentionally redacts fields that look like tokens, authorization val
|
|||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| `HOST` | `127.0.0.1` | Listen address (Docker sets `0.0.0.0`) |
|
| `HOST` | `127.0.0.1` | Listen address (Docker sets `0.0.0.0`) |
|
||||||
| `PORT` | `3000` | HTTP port |
|
| `PORT` | `3000` | HTTP port |
|
||||||
|
| `TRUST_PROXY` | `0` | Trusted reverse-proxy hop count for resolving the client address. `0` (or any non-numeric value) ignores `X-Forwarded-For` and uses the socket peer; set it only to the exact number of proxy hops you operate |
|
||||||
| `DATA_PATH` | `./data` | Persistent data directory |
|
| `DATA_PATH` | `./data` | Persistent data directory |
|
||||||
| `TZ` | system zone (`UTC` in containers) | IANA time zone used for dashboard and calendar dates, for example `Europe/Rome` |
|
| `TZ` | system zone (`UTC` in containers) | IANA time zone used for dashboard and calendar dates, for example `Europe/Rome` |
|
||||||
| `MAX_FILE_SIZE_MB` | `100` | Maximum manual SQLite upload size |
|
| `MAX_FILE_SIZE_MB` | `100` | Maximum manual SQLite upload size |
|
||||||
@@ -199,6 +200,8 @@ The logger intentionally redacts fields that look like tokens, authorization val
|
|||||||
| `GOOGLE_BOOKS_API_KEY` | empty | Optional Google Books API key for higher/identified quota; native Google Books discovery also works without one |
|
| `GOOGLE_BOOKS_API_KEY` | empty | Optional Google Books API key for higher/identified quota; native Google Books discovery also works without one |
|
||||||
| `COVER_WORKER_CONCURRENCY` | `2` | Number of books resolved concurrently (clamped to 1–4) |
|
| `COVER_WORKER_CONCURRENCY` | `2` | Number of books resolved concurrently (clamped to 1–4) |
|
||||||
|
|
||||||
|
Request handling is hardened independently of configuration: HTTP timeouts are pinned (`headersTimeout` 60s, `requestTimeout` 300s, `keepAliveTimeout` 5s) instead of inheriting Node's runtime defaults; pairing attempts are rate limited per client address and the in-memory attempt table is bounded; `/api/backup` is single-flight, so a concurrent request is rejected with `429` rather than writing a second archive; and the unauthenticated plugin ping is logged at most once per interval.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -206,7 +209,7 @@ npm test
|
|||||||
npm run fixture -- /tmp/fixture-statistics.sqlite3
|
npm run fixture -- /tmp/fixture-statistics.sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers incremental sync cursors/overlap, changed-sidecar/tombstone plumbing, sync diagnostics, cover job/history/candidate behavior, backup archive contents, SQLite validation/idempotence, KOReader-manual filtering, pairing state, authenticated sync, annotation replacement/deletion behavior, multilingual/series-title cover matching, ISBN enrichment, bad-image fallback, embedded-cover plumbing, Calibre CLI argument safety, Calibre identify fallback, direct cover-only/Google Images fallback, preservation of Calibre Google/ISBN identifiers, deterministic Google Books ID cover recovery, Calibre cover acceptance behavior, outdated-plugin warnings, browser CSRF handling, dedicated book routing, live pairing polling, the filterable heatmap and reading-time trend graph, cross-source reading-session deduplication, and plugin network diagnostics.
|
The suite covers incremental sync cursors/overlap, changed-sidecar/tombstone plumbing, sync diagnostics, cover job/history/candidate behavior, backup archive contents, SQLite validation/idempotence, KOReader-manual filtering, pairing state, authenticated sync, annotation replacement/deletion behavior, multilingual/series-title cover matching, ISBN enrichment, bad-image fallback, embedded-cover plumbing, Calibre CLI argument safety, Calibre identify fallback, direct cover-only/Google Images fallback, preservation of Calibre Google/ISBN identifiers, deterministic Google Books ID cover recovery, Calibre cover acceptance behavior, outdated-plugin warnings, browser CSRF handling, dedicated book routing, live pairing polling, the filterable heatmap and reading-time trend graph, cross-source reading-session deduplication, plugin network diagnostics, and HTTP server hardening (pairing rate limiting, trusted-proxy client resolution, ping log throttling, and backup serialization).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -231,7 +234,7 @@ The frontend is plain semantic HTML/CSS/ES modules and the kovi backend itself u
|
|||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
kovi does not implement web user accounts. Keep the web UI on a trusted LAN/private network, or place it behind an authenticated reverse proxy. Device plugin endpoints are separately authenticated with revocable tokens. See [SECURITY.md](SECURITY.md).
|
kovi does not implement web user accounts, and the default deployment is unauthenticated: any client that can reach the port can read the library, trigger exports, and download backups. Keep the web UI on a trusted LAN/private network, or place it behind an authenticated reverse proxy. `TRUST_PROXY` only corrects client-address resolution behind a proxy you control; it is not access control. Device plugin endpoints are separately authenticated with revocable tokens. See [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
kovi is designed for personal/self-hosted deployments. It intentionally avoids accounts in the MVP, so the web UI should be treated like an administrative interface: do not expose it directly to the public internet without an authenticated reverse proxy or private network such as Tailscale.
|
kovi is designed for personal/self-hosted deployments. It intentionally avoids accounts in the MVP, so the web UI should be treated like an administrative interface: do not expose it directly to the public internet without an authenticated reverse proxy or private network such as Tailscale.
|
||||||
|
|
||||||
|
The default deployment is therefore unauthenticated: read endpoints, CSV/Markdown exports, and `/api/backup` are reachable by any client that can connect. kovi mitigates abuse — a same-origin CSRF token, a bounded per-source pairing rate limit, explicit request timeouts, a single-flight backup guard, and throttled plugin-ping logging — but none of these authenticate a caller. Network reachability is the access-control boundary.
|
||||||
|
|
||||||
## Import safety
|
## Import safety
|
||||||
|
|
||||||
- Browser database uploads use a raw `PUT` body and are streamed into a unique, mode-0600 temporary file.
|
- Browser database uploads use a raw `PUT` body and are streamed into a unique, mode-0600 temporary file.
|
||||||
@@ -14,6 +16,7 @@ kovi is designed for personal/self-hosted deployments. It intentionally avoids a
|
|||||||
## Plugin safety
|
## Plugin safety
|
||||||
|
|
||||||
- KOReader devices pair with a one-use code that expires after 10 minutes.
|
- KOReader devices pair with a one-use code that expires after 10 minutes.
|
||||||
|
- Pairing attempts are rate limited per client address (30 per 10-minute window), and the in-memory attempt table is bounded, so an unauthenticated client cannot grow it without limit.
|
||||||
- Successful pairing issues a random per-device bearer token; only its SHA-256 hash is stored.
|
- Successful pairing issues a random per-device bearer token; only its SHA-256 hash is stored.
|
||||||
- Tokens are independently revocable from the Devices screen.
|
- Tokens are independently revocable from the Devices screen.
|
||||||
- Plugin JSON has strict request-size and row-count limits.
|
- Plugin JSON has strict request-size and row-count limits.
|
||||||
@@ -35,6 +38,8 @@ kovi is designed for personal/self-hosted deployments. It intentionally avoids a
|
|||||||
|
|
||||||
The application does not enable permissive CORS. Browser API access is same-origin. The intentional outbound traffic is automatic cover lookup against Open Library, the Google Books API, Calibre metadata providers, and fixed Google Books cover URLs learned from Calibre identifiers. Plugin cover uploads are inbound, authenticated device requests.
|
The application does not enable permissive CORS. Browser API access is same-origin. The intentional outbound traffic is automatic cover lookup against Open Library, the Google Books API, Calibre metadata providers, and fixed Google Books cover URLs learned from Calibre identifiers. Plugin cover uploads are inbound, authenticated device requests.
|
||||||
|
|
||||||
|
`TRUST_PROXY` is an opt-in integer defaulting to `0`. At `0` — and for any non-numeric value — kovi ignores `X-Forwarded-For` and keys rate limits and log fields on the socket peer, so a spoofed header changes nothing. Set it only to the exact number of reverse-proxy hops you operate; kovi then resolves the client address from `X-Forwarded-For`, validates the selected hop as a literal IP address, and falls back to the socket peer when the chain is too short or the value is not an IP. This changes client-address resolution only; it is not authentication or access control.
|
||||||
|
|
||||||
## Calibre cover resolver
|
## Calibre cover resolver
|
||||||
|
|
||||||
kovi can invoke Debian's unmodified `fetch-ebook-metadata` and `calibre-debug` executables as separate, non-root subprocesses when native cover sources fail. The second subprocess runs a fixed Calibre cover-downloader command so Calibre's cover-only sources (notably Google Images) can run even when metadata identification fails. Book title/author/ISBN values are passed using Node's `execFile` argument array or fixed environment variables consumed by fixed Python code; kovi does not interpolate ebook metadata into executable source or a shell command. Each invocation has a timeout and output cap, uses a private temporary Calibre configuration directory, and its returned file is independently checked for supported image magic bytes and size before being cached. When Calibre identifies a Google Books ID but cannot download the image itself, kovi may request Calibre's documented fixed Google Books cover URL on `books.google.com`; redirects stay restricted to allowlisted Google Books/Googleusercontent hosts, known Google dummy-cover hashes are rejected, and the same image type/size checks still apply. Set `CALIBRE_COVER_RESOLVER=false` to disable this fallback.
|
kovi can invoke Debian's unmodified `fetch-ebook-metadata` and `calibre-debug` executables as separate, non-root subprocesses when native cover sources fail. The second subprocess runs a fixed Calibre cover-downloader command so Calibre's cover-only sources (notably Google Images) can run even when metadata identification fails. Book title/author/ISBN values are passed using Node's `execFile` argument array or fixed environment variables consumed by fixed Python code; kovi does not interpolate ebook metadata into executable source or a shell command. Each invocation has a timeout and output cap, uses a private temporary Calibre configuration directory, and its returned file is independently checked for supported image magic bytes and size before being cached. When Calibre identifies a Google Books ID but cannot download the image itself, kovi may request Calibre's documented fixed Google Books cover URL on `books.google.com`; redirects stay restricted to allowlisted Google Books/Googleusercontent hosts, known Google dummy-cover hashes are rejected, and the same image type/size checks still apply. Set `CALIBRE_COVER_RESOLVER=false` to disable this fallback.
|
||||||
+19
-7
@@ -1,4 +1,5 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import fsp from 'node:fs/promises';
|
import fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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_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 TRUST_PROXY_HOPS = Number.parseInt(process.env.TRUST_PROXY || '0', 10) || 0;
|
||||||
const HEADERS_TIMEOUT_MS = 60_000;
|
const HEADERS_TIMEOUT_MS = 60_000;
|
||||||
const REQUEST_TIMEOUT_MS = 300_000;
|
const REQUEST_TIMEOUT_MS = 300_000;
|
||||||
const KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
const KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
||||||
@@ -103,13 +105,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});
|
||||||
}
|
}
|
||||||
|
// 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) {
|
function prunePairAttempts(now) {
|
||||||
for (const [key,row] of pairAttempts) {
|
for (const [key,row] of pairAttempts) {
|
||||||
if (now-row.start > PAIR_WINDOW_MS) pairAttempts.delete(key);
|
if (now-row.start > PAIR_WINDOW_MS) pairAttempts.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function checkPairRate(req) {
|
function checkPairRate(req) {
|
||||||
const key=req.socket.remoteAddress;
|
const key=clientAddress(req);
|
||||||
if(!key) return;
|
if(!key) return;
|
||||||
const now=Date.now();
|
const now=Date.now();
|
||||||
const row=pairAttempts.get(key) || {start:now,count:0};
|
const row=pairAttempts.get(key) || {start:now,count:0};
|
||||||
@@ -137,7 +149,7 @@ async function handleApi(req,res,url) {
|
|||||||
const now=Date.now();
|
const now=Date.now();
|
||||||
if(now-lastPingLogAt>=PING_LOG_INTERVAL_MS){
|
if(now-lastPingLogAt>=PING_LOG_INTERVAL_MS){
|
||||||
lastPingLogAt=now;
|
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());
|
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 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)});
|
const paired=consumePairingCode(db,body.code,{deviceId:body.device_id,model:cleanText(body.model,120),version:cleanText(body.version,40)});
|
||||||
if(!paired) {
|
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());
|
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});
|
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());
|
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') {
|
if(req.method==='POST' && url.pathname==='/api/plugin/import') {
|
||||||
const device=authenticateDevice(db,req.headers.authorization);
|
const device=authenticateDevice(db,req.headers.authorization);
|
||||||
if(!device) {
|
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());
|
return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders());
|
||||||
}
|
}
|
||||||
const body=await readJson(req);
|
const body=await readJson(req);
|
||||||
@@ -236,7 +248,7 @@ async function handleApi(req,res,url) {
|
|||||||
if(req.method==='POST' && url.pathname==='/api/plugin/cover') {
|
if(req.method==='POST' && url.pathname==='/api/plugin/cover') {
|
||||||
const device=authenticateDevice(db,req.headers.authorization);
|
const device=authenticateDevice(db,req.headers.authorization);
|
||||||
if(!device) {
|
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());
|
return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders());
|
||||||
}
|
}
|
||||||
const md5=cleanText(req.headers['x-kovi-book-md5'],128);
|
const md5=cleanText(req.headers['x-kovi-book-md5'],128);
|
||||||
@@ -266,7 +278,7 @@ async function handleApi(req,res,url) {
|
|||||||
cb(null,chunk);
|
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 {
|
try {
|
||||||
await pipeline(req,limiter,fs.createWriteStream(tempPath,{flags:'wx',mode:0o600}));
|
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});
|
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});
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const SERVER = fileURLToPath(new URL('../server.mjs', import.meta.url));
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.on('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(extraEnv = {}) {
|
||||||
|
const dir = mkdtempSync(path.join(os.tmpdir(), 'kovi-server-'));
|
||||||
|
const port = await freePort();
|
||||||
|
const child = spawn(process.execPath, [SERVER], {
|
||||||
|
cwd: path.dirname(SERVER),
|
||||||
|
env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', DATA_PATH: dir, CALIBRE_COVER_RESOLVER: 'false', ...extraEnv },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
child.stdout.on('data', (d) => (output += d));
|
||||||
|
child.stderr.on('data', (d) => (output += d));
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
let healthy = false;
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
while (Date.now() < deadline && !healthy) {
|
||||||
|
try { healthy = (await fetch(`${base}/api/health`)).ok; } catch {}
|
||||||
|
if (!healthy) await sleep(100);
|
||||||
|
}
|
||||||
|
if (!healthy) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
throw new Error(`kovi did not start:\n${output}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
logs: () => output,
|
||||||
|
async stop() {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
const exited = new Promise((r) => child.once('exit', r));
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
await exited;
|
||||||
|
}
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = (base, pathname, body, headers = {}) => fetch(`${base}${pathname}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', ...headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pairing attempts are rate limited per source and ignore X-Forwarded-For unless trusted', async () => {
|
||||||
|
const server = await startServer();
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const res = await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': `203.0.113.${i + 1}` });
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
}
|
||||||
|
const blocked = await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.250' });
|
||||||
|
assert.equal(blocked.status, 429);
|
||||||
|
assert.match((await blocked.json()).error, /Too many pairing attempts/);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TRUST_PROXY keys the rate limit on the forwarded client, validates hops, and logs it', async () => {
|
||||||
|
const server = await startServer({ TRUST_PROXY: '1' });
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.10' })).status, 401);
|
||||||
|
}
|
||||||
|
// A different forwarded client still has its own budget.
|
||||||
|
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.11' })).status, 401);
|
||||||
|
// The exhausted client is now blocked.
|
||||||
|
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': '203.0.113.10' })).status, 429);
|
||||||
|
// A non-IP forwarded value is rejected and falls back to the direct peer.
|
||||||
|
assert.equal((await post(server.base, '/api/plugin/pair', { code: 'invalid-code' }, { 'x-forwarded-for': 'not-an-ip' })).status, 401);
|
||||||
|
await sleep(100);
|
||||||
|
assert.match(server.logs(), /"remote":"203\.0\.113\.10"/);
|
||||||
|
assert.match(server.logs(), /"remote":"127\.0\.0\.1"/);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin ping keeps responding but logs at most once per interval', async () => {
|
||||||
|
const server = await startServer();
|
||||||
|
try {
|
||||||
|
assert.equal((await fetch(`${server.base}/api/plugin/ping`)).status, 200);
|
||||||
|
assert.equal((await fetch(`${server.base}/api/plugin/ping`)).status, 200);
|
||||||
|
await sleep(100);
|
||||||
|
assert.equal((server.logs().match(/plugin\.ping/g) || []).length, 1);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent backup requests are serialized with 429', async () => {
|
||||||
|
const server = await startServer();
|
||||||
|
try {
|
||||||
|
const statuses = await Promise.all(Array.from({ length: 5 }, () => fetch(`${server.base}/api/backup`).then((r) => r.status)));
|
||||||
|
assert.ok(statuses.includes(200), `expected a successful backup, got ${statuses}`);
|
||||||
|
assert.ok(statuses.includes(429), `expected a rejected concurrent backup, got ${statuses}`);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('server pins explicit HTTP timeouts instead of relying on runtime defaults', () => {
|
||||||
|
const source = readFileSync(SERVER, 'utf8');
|
||||||
|
assert.match(source, /server\.headersTimeout\s*=\s*HEADERS_TIMEOUT_MS/);
|
||||||
|
assert.match(source, /server\.requestTimeout\s*=\s*REQUEST_TIMEOUT_MS/);
|
||||||
|
assert.match(source, /server\.keepAliveTimeout\s*=\s*KEEP_ALIVE_TIMEOUT_MS/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user