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
@@ -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