mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 04:12:17 +03:00
Merged via /merge-batch (2026-08-26, v3.8.51). Validado: typecheck/complexity/file-size/changelog verdes, 18/18 testes de authz passando (com o isolamento correto de DATA_DIR — meu primeiro run isolado falhou por eu não ter carregado tests/_setup/isolateDataDir.ts, não é bug seu). Obrigado pela contribuição.
168 lines
6.7 KiB
TypeScript
168 lines
6.7 KiB
TypeScript
// Repro test for #9033 — IP blacklist does not block on direct connections
|
|
// and does not propagate without restart.
|
|
// D1: blacklisted IP on a DIRECT connection (trusted peer stamp, no XFF) is NOT blocked
|
|
// D2: persisted config written after first load is never re-read by the loaded instance
|
|
// Bonus: ipFilterModeSchema rejects "whitelist-priority" that the UI offers and checkIP implements
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { NextRequest } from "next/server";
|
|
import { wrapRequestListenerWithPeerStamp } from "../../../scripts/dev/peer-stamp.mjs";
|
|
|
|
const TEST_DATA_DIR = path.join(process.env.DATA_DIR!, "probe-9033-repro");
|
|
// NOTE: Not reassigning process.env.DATA_DIR at module scope because
|
|
// node --test spawns test files as worker threads sharing process.env.
|
|
// A module-level DATA_DIR override would leak to ALL concurrently running
|
|
// workers, causing them to share the same SQLite file and race on it (#9541).
|
|
// isolateDataDir.ts (--import) already set DATA_DIR to a unique temp dir per
|
|
// process; we use a subdirectory within it instead.
|
|
|
|
process.env.JWT_SECRET = "test-secret-9033";
|
|
|
|
const core = await import("../../../src/lib/db/core.ts");
|
|
const ipFilter = await import("../../../open-sse/services/ipFilter.ts");
|
|
const pipeline = await import("../../../src/server/authz/pipeline.ts");
|
|
|
|
const ORIGINAL_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
if (ORIGINAL_STAMP_TOKEN === undefined) delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
|
else process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_STAMP_TOKEN;
|
|
});
|
|
|
|
test.beforeEach(() => {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
ipFilter.resetIPFilter();
|
|
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
|
});
|
|
|
|
const BLOCKED = "203.0.113.99";
|
|
|
|
function makeRequest(extraHeaders: Record<string, string> = {}) {
|
|
return new NextRequest("http://localhost/v1/models", {
|
|
headers: { ...extraHeaders },
|
|
});
|
|
}
|
|
|
|
test("D1: blacklisted IP is blocked on a DIRECT connection (trusted peer stamp, no XFF)", async () => {
|
|
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "stamp-tok";
|
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
|
ipFilter.addToBlacklist(BLOCKED);
|
|
|
|
// Simulate a direct connection: the peer stamp says the client is BLOCKED,
|
|
// and there is no x-forwarded-for header (direct connection, not via proxy).
|
|
const res = await pipeline.runAuthzPipeline(
|
|
makeRequest({ "x-omniroute-peer-ip": "stamp-tok|203.0.113.99" }),
|
|
{ enforce: true }
|
|
);
|
|
|
|
assert.equal(res.status, 403, `direct blacklisted IP must be blocked, got status=${res.status}`);
|
|
});
|
|
|
|
test("D2: persisted config written after first load is honored WITHOUT restart", async () => {
|
|
// Simulate: the settings route (separate module instance) writes config to DB.
|
|
// The ipFilter module instance (already loaded) must re-read it.
|
|
// First, load the module once (simulates initial load from a previous request).
|
|
ipFilter.resetIPFilter();
|
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
|
ipFilter.addToBlacklist(BLOCKED);
|
|
assert.equal(ipFilter.checkIP(BLOCKED).allowed, false, "blacklist must be active after config");
|
|
|
|
// Now simulate a "settings route" write: write directly to the DB key_value table
|
|
// with a DIFFERENT config (e.g. empty blacklist, effectively "allow all").
|
|
const db = core.getDbInstance();
|
|
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
|
"ipFilter",
|
|
"config",
|
|
JSON.stringify({ enabled: true, mode: "blacklist", blacklist: [], whitelist: [] })
|
|
);
|
|
|
|
// Without a restart, the ipFilter instance must re-read from DB on next checkIP call.
|
|
// The BLOCKED IP should NOT be blocked anymore because the DB config has empty blacklist.
|
|
const result = ipFilter.checkIP(BLOCKED);
|
|
assert.equal(
|
|
result.allowed,
|
|
true,
|
|
`stale-config enforcer must re-read DB, got: ${JSON.stringify(result)}`
|
|
);
|
|
});
|
|
|
|
test("D3: behind reverse proxy (peer stamp=loopback + via-proxy marker + XFF=blacklisted IP) still blocks", async () => {
|
|
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "stamp-tok";
|
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
|
ipFilter.addToBlacklist(BLOCKED);
|
|
|
|
// Behind a reverse proxy: the peer IP is the proxy hop (127.0.0.1),
|
|
// the via-proxy marker is set, and the real client IP is in x-forwarded-for.
|
|
const res = await pipeline.runAuthzPipeline(
|
|
makeRequest({
|
|
"x-omniroute-peer-ip": "stamp-tok|127.0.0.1",
|
|
"x-omniroute-via-proxy": "stamp-tok|1",
|
|
"x-forwarded-for": BLOCKED,
|
|
}),
|
|
{ enforce: true }
|
|
);
|
|
|
|
assert.equal(
|
|
res.status,
|
|
403,
|
|
`behind-proxy blacklisted IP must be blocked, got status=${res.status}`
|
|
);
|
|
});
|
|
|
|
test("D4: behind Cloudflare (cf-connecting-ip + via-proxy marker, no XFF) blocks the client IP", async () => {
|
|
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "stamp-tok";
|
|
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
|
ipFilter.addToBlacklist(BLOCKED);
|
|
|
|
// Simulate the real custom-server path: the request arrives at the Node HTTP
|
|
// server, stampPeerIp runs first, then the request (with stamped headers) is
|
|
// forwarded into the Next.js pipeline. Use a Cloudflare edge IP as the socket
|
|
// peer so cf-connecting-ip is trusted as a proxy marker.
|
|
const stampedHeaders: Record<string, string> = {};
|
|
const wrapped = wrapRequestListenerWithPeerStamp((_req) => {
|
|
Object.assign(stampedHeaders, _req.headers);
|
|
});
|
|
wrapped(
|
|
{
|
|
headers: { "cf-connecting-ip": BLOCKED },
|
|
socket: { remoteAddress: "172.71.150.1" },
|
|
} as never,
|
|
{} as never
|
|
);
|
|
|
|
assert.equal(
|
|
stampedHeaders["x-omniroute-via-proxy"],
|
|
"stamp-tok|1",
|
|
"Cloudflare request must be stamped as via-proxy"
|
|
);
|
|
assert.equal(
|
|
stampedHeaders["x-omniroute-peer-ip"],
|
|
"stamp-tok|172.71.150.1",
|
|
"Cloudflare request must have the edge IP stamped"
|
|
);
|
|
|
|
const res = await pipeline.runAuthzPipeline(makeRequest(stampedHeaders), { enforce: true });
|
|
|
|
assert.equal(
|
|
res.status,
|
|
403,
|
|
`behind-Cloudflare blacklisted IP must be blocked via cf-connecting-ip, got status=${res.status}`
|
|
);
|
|
});
|
|
|
|
test("Bonus: ipFilterModeSchema accepts whitelist-priority", async () => {
|
|
const { ipFilterModeSchema } = await import("../../../src/shared/validation/schemas/misc.ts");
|
|
const result = ipFilterModeSchema.safeParse("whitelist-priority");
|
|
assert.equal(
|
|
result.success,
|
|
true,
|
|
`ipFilterModeSchema must accept "whitelist-priority", got: ${JSON.stringify(result)}`
|
|
);
|
|
});
|