Files
OmniRoute/tests/unit/proxySubscription.service.test.ts
adevwithpurpose e7718b0423 fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs
The subscription fetch guard (fetchGuard.ts) unconditionally blocked all
loopback/private IP ranges as SSRF protection, but the same feature already
permits loopback for the routing half (coreEndpoint.ts's
ALLOWED_LOCAL_CORE_HOSTS) — so an operator could route traffic through a
loopback core but could not fetch a proxy list from a loopback HTTP server.

Make the fetch guard local-first by reusing the existing
areLocalProviderUrlsAllowed() policy (default ON) from
outboundUrlGuardPolicy.ts: loopback/private hosts are now allowed as fetch
targets by default, while cloud-metadata/link-local (169.254.0.0/16, incl.
169.254.169.254 IMDS) and the unspecified address stay blocked
unconditionally, mirroring the provider-validation guard's "block-metadata"
mode. Callers that want the old strict behavior can pass
{ allowLocal: false }.

Closes #10158.
2026-08-14 18:39:08 -03:00

287 lines
9.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sub-svc-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const proxies = await import("../../src/lib/db/proxies.ts");
const sub = await import("../../src/lib/proxySubscription/index.ts");
function reset() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function nowIso() {
return new Date().toISOString();
}
function insertSubscription(
db: ReturnType<typeof core.getDbInstance>,
id: string,
mode: "global" | "rule",
opts: { enabled?: boolean; ruleProviders?: string[] } = {}
) {
db.prepare(
`INSERT INTO proxy_subscriptions
(id, name, url, enabled, mode, rule_providers, update_interval_minutes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'empty', ?, ?)`
).run(
id,
`sub-${id}`,
`https://example.com/${id}`,
opts.enabled === false ? 0 : 1,
mode,
opts.ruleProviders ? JSON.stringify(opts.ruleProviders) : null,
60,
nowIso(),
nowIso()
);
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("global subscription binds its pool to the global scope and is resolvable", async () => {
await reset();
const db = core.getDbInstance();
insertSubscription(db, "s1", "global", { enabled: true });
const created = await proxies.createProxy({
name: "node1",
type: "http",
host: "10.0.0.1",
port: 8080,
source: "subscription",
subscriptionId: "s1",
status: "active",
});
await sub.applySubscription("s1");
const resolved = await proxies.resolveProxyForConnectionFromRegistry("conn-xyz");
assert.ok(resolved, "expected a global proxy to be resolved");
assert.equal(resolved?.proxy.host, "10.0.0.1");
const flag = db
.prepare("SELECT value FROM key_value WHERE namespace='settings' AND key='proxyEnabled'")
.get() as { value: string };
assert.equal(JSON.parse(flag.value), true);
// Unapply -> proxy removed from global scope, proxyEnabled recomputed to false.
await sub.unapplySubscription("s1");
const resolved2 = await proxies.resolveProxyForConnectionFromRegistry("conn-xyz");
assert.equal(resolved2, null, "expected direct (no proxy) after unapply");
const flag2 = db
.prepare("SELECT value FROM key_value WHERE namespace='settings' AND key='proxyEnabled'")
.get() as { value: string };
assert.equal(JSON.parse(flag2.value), false);
});
test("rule subscription binds only the selected provider scope", async () => {
await reset();
const db = core.getDbInstance();
insertSubscription(db, "s2", "rule", { enabled: true, ruleProviders: ["provA"] });
const created = await proxies.createProxy({
name: "node2",
type: "http",
host: "10.0.0.2",
port: 8080,
source: "subscription",
subscriptionId: "s2",
status: "active",
});
await sub.applySubscription("s2");
db.prepare(
"INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?,?,?,?)"
).run("connA", "provA", nowIso(), nowIso());
db.prepare(
"INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?,?,?,?)"
).run("connB", "provB", nowIso(), nowIso());
const rA = await proxies.resolveProxyForConnectionFromRegistry("connA");
assert.ok(rA, "provider A should resolve the rule proxy");
assert.equal(rA?.proxy.host, "10.0.0.2");
const rB = await proxies.resolveProxyForConnectionFromRegistry("connB");
assert.equal(rB, null, "provider B is not in the rule set -> direct");
});
test("fail-closed: a dead subscription proxy blocks the connection instead of leaking", async () => {
await reset();
const db = core.getDbInstance();
insertSubscription(db, "s3", "global", { enabled: true });
await proxies.createProxy({
name: "deadnode",
type: "http",
host: "10.0.0.9",
port: 1,
source: "subscription",
subscriptionId: "s3",
status: "dead",
});
await sub.applySubscription("s3");
const flag = db
.prepare("SELECT value FROM key_value WHERE namespace='settings' AND key='proxyEnabled'")
.get() as { value: string };
assert.equal(JSON.parse(flag.value), true);
// Dead proxy is excluded from resolution -> request would go direct.
const resolved = await proxies.resolveProxyForConnectionFromRegistry("connZ");
assert.equal(resolved, null);
// But the operator assigned a (now dead) proxy, so this must be blocked.
const blocked = proxies.hasBlockingProxyAssignment("connZ");
assert.equal(blocked, true);
});
test("deleteSubscription unbinds and removes its proxy rows", async () => {
await reset();
const db = core.getDbInstance();
insertSubscription(db, "s4", "global", { enabled: true });
await proxies.createProxy({
name: "node4",
type: "http",
host: "10.0.0.4",
port: 8080,
source: "subscription",
subscriptionId: "s4",
status: "active",
});
await sub.applySubscription("s4");
const ok = await sub.deleteSubscription("s4");
assert.equal(ok, true);
const rows = db
.prepare("SELECT id FROM proxy_registry WHERE subscription_id = ?")
.all("s4") as Array<{ id: string }>;
assert.equal(rows.length, 0, "subscription proxy rows should be removed");
const assignments = db
.prepare("SELECT 1 FROM proxy_assignments a JOIN proxy_registry p ON p.id=a.proxy_id WHERE p.source='subscription' LIMIT 1")
.get();
assert.equal(assignments, undefined, "no subscription proxy should remain assigned");
const subRow = db.prepare("SELECT 1 FROM proxy_subscriptions WHERE id='s4'").get();
assert.equal(subRow, undefined);
});
test("global→rule switch re-evaluates binding: drops global, binds the selected provider scope", async () => {
await reset();
const db = core.getDbInstance();
// Seed a proxy node directly (avoids a network fetch for this part).
await proxies.createProxy({
name: "node5",
type: "http",
host: "10.0.0.5",
port: 8080,
source: "subscription",
subscriptionId: "s5",
status: "active",
});
// Start in GLOBAL mode and bind the pool to the global scope.
insertSubscription(db, "s5", "global", { enabled: true });
await sub.applySubscription("s5");
const before = await proxies.resolveProxyForConnectionFromRegistry("connAny");
assert.ok(before, "global mode should resolve the node for any connection");
assert.equal(before?.proxy.host, "10.0.0.5");
// Switch to RULE mode targeting provider provA. updateSubscription must
// detach the previous global binding (unapplySubscription) and re-bind the
// pool to the selected provider scope. Stub fetch so syncSubscription's
// re-fetch returns a body that keeps node5 around.
const realFetch = globalThis.fetch;
globalThis.fetch = (async () => ({
ok: true,
text: async () =>
"proxies:\n - name: node5\n type: http\n server: 10.0.0.5\n port: 8080\n",
})) as unknown as typeof fetch;
try {
await sub.updateSubscription("s5", { mode: "rule", ruleProviders: ["provA"] });
} finally {
globalThis.fetch = realFetch;
}
// The global binding must be gone.
const afterGlobal = await proxies.resolveProxyForConnectionFromRegistry("connAny");
assert.equal(afterGlobal, null, "global binding should be dropped after switching to rule mode");
// The provider-scope binding must now resolve the node.
db.prepare(
"INSERT INTO provider_connections (id, provider, created_at, updated_at) VALUES (?,?,?,?)"
).run("connA", "provA", nowIso(), nowIso());
const afterRule = await proxies.resolveProxyForConnectionFromRegistry("connA");
assert.ok(afterRule, "rule mode should bind the node to provider provA");
assert.equal(afterRule?.proxy.host, "10.0.0.5");
});
// ─────────────────── Regression: #10158 local proxy subscription ───────────────────
// Promoted from the TDD probe (was RED on release/v3.8.50: createSubscription against a
// real local (127.0.0.1) HTTP server failed with "Fetch failed: Subscription URL is not
// allowed (scheme or host blocked)" even though coreEndpoint.ts already permits routing
// through a loopback core). Uses a REAL local HTTP server (not a fetch stub) so the fix
// is proven end-to-end through assertSafeFetchTarget's SSRF guard.
function startLocalSubscriptionServer(
body: string
): Promise<{ url: string; close: () => Promise<void> }> {
return new Promise((resolve) => {
const srv = http.createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(body);
});
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") throw new Error("no addr");
resolve({
url: `http://127.0.0.1:${addr.port}/list`,
close: () => new Promise((r) => srv.close(() => r())),
});
});
});
}
test("#10158: createSubscription against a real local (127.0.0.1) HTTP server syncs ok", async () => {
await reset();
const LIST_BODY = [
"proxies:",
" - name: local-node",
" type: http",
" server: 127.0.0.1",
" port: 8080",
].join("\n");
const { url, close } = await startLocalSubscriptionServer(LIST_BODY);
try {
const created = await sub.createSubscription({
name: "local-list",
url,
enabled: true,
mode: "global",
});
assert.equal(
created.status,
"ok",
`expected ok, got status=${created.status} error=${created.error}`
);
assert.ok((created.lastNodes ?? []).length >= 1, "expected at least one parsed node");
} finally {
await close();
}
});