Compare commits

..

1 Commits

Author SHA1 Message Date
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
9 changed files with 226 additions and 79 deletions

View File

@@ -1 +0,0 @@
- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104)

View File

@@ -0,0 +1 @@
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)

View File

@@ -8435,7 +8435,12 @@ components:
type: string
url:
type: string
description: Redacted subscription URL.
description: >-
Redacted subscription URL. May be a local/loopback address
(e.g. `http://127.0.0.1:8080/list`) — local-first fetch targets
are allowed by default (`OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS`);
cloud-metadata / link-local endpoints (169.254.0.0/16) are always
blocked.
enabled:
type: boolean
mode:

View File

@@ -397,10 +397,9 @@ function sanitizeAntigravityGeminiRequest(
* `"assistant"`). Mirrors the trailing-strip pop-loop already used for Mistral
* (#3396), Copilot (#5802), and the CC-bridge in `claudeCodeCompatible.ts`.
*
* Wired in by the caller for both the Claude path (`isClaude`) and native Gemini
* models (`isGemini`, #10104) — newer Gemini endpoints reject a trailing `model` turn
* with the same "ending with a model turn" class of 400 that Claude hits via Vertex.
* Other model families routed through Antigravity are left untouched.
* Scoped strictly to the Claude path by the caller (`isClaude` branch only) — native
* Gemini models via Antigravity must be unaffected, since Vertex-Claude is the only
* documented rejection surface.
*
* Guard: never strip `contents` down to empty — an empty `contents` array is itself
* an invalid request, so at least one entry (even a lone trailing "model" turn) is
@@ -598,14 +597,6 @@ export class AntigravityExecutor extends BaseExecutor {
const upstreamModel = await cleanModelName(model, modelIdOverride);
const isClaude = upstreamModel.toLowerCase().includes("claude");
// #10104: newer Gemini endpoints reject a request ending on a `model` turn with
// HTTP 400 "Requests ending with a model turn are not supported" — the same
// rejection surface Claude hits via Vertex (see stripTrailingAntigravityAssistantTurn's
// doc comment above). Native Gemini models routed through Antigravity (`agy/gemini-*`,
// e.g. the Gemini 3.x Flash/Pro tiers from PR #8013's catalog) need the same guarded
// strip. Scoped to models whose id names Gemini so unrelated model families are
// untouched; the strip itself never empties `contents` (see the guard above).
const isGemini = upstreamModel.toLowerCase().includes("gemini");
const baseBody = bodyRecord;
const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel)
? stripCloudCodeThinkingConfig(baseBody)
@@ -669,16 +660,11 @@ export class AntigravityExecutor extends BaseExecutor {
: normalizedRequest?.toolConfig,
};
// Note: sanitizeAntigravityGeminiRequest() applies a Claude-only field whitelist
// (dropping fields native Gemini requests may legitimately carry), so the Gemini
// branch only runs the trailing-turn strip — never the sanitize/whitelist step.
const transformedRequest = isClaude
? stripTrailingAntigravityAssistantTurn(
sanitizeAntigravityGeminiRequest(rawTransformedRequest)
)
: isGemini
? stripTrailingAntigravityAssistantTurn(rawTransformedRequest)
: rawTransformedRequest;
: rawTransformedRequest;
applyAntigravityGenerationDefaults(transformedRequest, upstreamModel);

View File

@@ -3,30 +3,57 @@
*
* The subscription URL is fetched server-side (see `subscriptionService
* .fetchSubscriptionContent`). Without a guard, an operator — or a compromised
* subscription link — could point OmniRoute at internal services or cloud
* metadata (SSRF). Only http/https to non-internal hosts are allowed:
* loopback / private / link-local (incl. 169.254.0.0/16 cloud metadata) /
* unspecified addresses are blocked.
* subscription link — could point OmniRoute at cloud metadata (SSRF). Only
* http/https to non-metadata hosts are allowed.
*
* Local-first (#10158): OmniRoute already lets an operator route ALL traffic
* through a loopback core (`coreEndpoint.ts` allows `127.0.0.1`/`::1`/
* `localhost`), so a subscription fetch target on loopback/private ranges is
* ALLOWED by default (`allowLocal: true`, matching the local-first default of
* `areLocalProviderUrlsAllowed()` in `src/shared/network/outboundUrlGuardPolicy
* .ts`) — mirroring that policy's "block-metadata" mode. Cloud-metadata /
* link-local (`169.254.0.0/16`, incl. `169.254.169.254` IMDS) and the
* unspecified address (`0.0.0.0/8`) are blocked UNCONDITIONALLY regardless of
* `allowLocal`, since they have no legitimate subscription-source use case.
* Callers that want the old strict (public-only) behavior pass
* `{ allowLocal: false }`.
*
* Hostname resolution is re-checked at fetch time (also using the IP-range
* helpers here) so a hostname that resolves to an internal address is still
* refused. Splitting the logic into pure functions keeps it unit-testable
* without DNS / the full stack.
* without DNS / the full stack. No `@/`-aliased or DB-backed imports here —
* the `allowLocal` policy decision is made by the caller (subscriptionService,
* which is already DB-backed) and passed in as a plain boolean.
*/
/** Only these URL schemes may be used to *fetch* a subscription. */
export const ALLOWED_FETCH_SCHEMES = new Set<string>(["http:", "https:"]);
// Blocked IPv4 ranges (base, mask) as 32-bit ints.
const BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
// Blocked UNCONDITIONALLY, regardless of `allowLocal` — the classic SSRF→cloud
// credential pivot; never a legitimate subscription source.
const ALWAYS_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
[0x00000000, 0xff000000], // 0.0.0.0/8 unspecified
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 link-local (incl. cloud metadata IMDS)
];
// Blocked only when `allowLocal` is false (strict/public-only mode).
const LOCAL_ONLY_BLOCKED_IPV4: ReadonlyArray<readonly [number, number]> = [
[0x7f000000, 0xff000000], // 127.0.0.0/8 loopback
[0x0a000000, 0xff000000], // 10.0.0.0/8 private
[0xac100000, 0xfff00000], // 172.16.0.0/12 private
[0xc0a80000, 0xffff0000], // 192.168.0.0/16 private
[0xa9fe0000, 0xffff0000], // 169.254.0.0/16 link-local (cloud metadata)
];
export interface FetchGuardOptions {
/**
* When true (default), loopback/private hosts are allowed as fetch targets
* ("local-first" — matches `areLocalProviderUrlsAllowed()`'s default). Cloud
* metadata / link-local is blocked unconditionally either way. Pass `false`
* to restore the strict public-only behavior.
*/
allowLocal?: boolean;
}
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
export function isIpv4Literal(host: string): boolean {
@@ -44,20 +71,24 @@ export function ipv4ToLong(host: string): number | null {
return (parts[0] * 16777216 + parts[1] * 65536 + parts[2] * 256 + parts[3]) >>> 0;
}
export function isIpv4Blocked(ip: string): boolean {
export function isIpv4Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
const allowLocal = opts.allowLocal ?? true;
const n = ipv4ToLong(ip);
if (n === null) return false;
// `&` yields a signed 32-bit int; coerce both sides to unsigned before
// comparing so masked results with the high bit set aren't negative.
return BLOCKED_IPV4.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
const ranges = allowLocal ? ALWAYS_BLOCKED_IPV4 : [...ALWAYS_BLOCKED_IPV4, ...LOCAL_ONLY_BLOCKED_IPV4];
return ranges.some(([base, mask]) => ((n & mask) >>> 0) === (base >>> 0));
}
/** Blocked IPv6 addresses: loopback, unspecified, link-local, ULA. */
export function isIpv6Blocked(ip: string): boolean {
/** Blocked IPv6 addresses: unspecified/link-local always; loopback/ULA only when strict. */
export function isIpv6Blocked(ip: string, opts: FetchGuardOptions = {}): boolean {
const allowLocal = opts.allowLocal ?? true;
const h = ip.toLowerCase();
if (h === "::") return true; // unspecified — always blocked
if (h.startsWith("fe80")) return true; // link-local — always blocked
if (allowLocal) return false;
if (h === "::1") return true; // loopback
if (h === "::") return true; // unspecified
if (h.startsWith("fe80")) return true; // link-local
if (h.startsWith("fc") || h.startsWith("fd")) return true; // unique local
return false;
}
@@ -78,20 +109,22 @@ export function isIpLiteral(host: string): boolean {
* the `dns` module convention (4 = IPv4, 6 = IPv6; missing ⇒ treat as v4).
*/
export function isAnyResolvedAddressBlocked(
addrs: ReadonlyArray<{ address: string; family?: number }>
addrs: ReadonlyArray<{ address: string; family?: number }>,
opts: FetchGuardOptions = {}
): boolean {
return addrs.some(({ address, family }) => {
const fam = family === 6 ? 6 : 4;
return fam === 6 ? isIpv6Blocked(address) : isIpv4Blocked(address);
return fam === 6 ? isIpv6Blocked(address, opts) : isIpv4Blocked(address, opts);
});
}
/**
* Structural check (no DNS). True only if the scheme is allowed AND, when the
* host is an IP literal, it is not in a blocked range. Hostnames pass the
* structural check — they are resolved and re-checked at fetch time.
* host is an IP literal, it is not in a blocked range for the given
* `allowLocal` mode. Hostnames pass the structural check — they are resolved
* and re-checked at fetch time.
*/
export function isSubscriptionFetchUrlAllowed(url: string): boolean {
export function isSubscriptionFetchUrlAllowed(url: string, opts: FetchGuardOptions = {}): boolean {
let u: URL;
try {
u = new URL(url);
@@ -104,8 +137,8 @@ export function isSubscriptionFetchUrlAllowed(url: string): boolean {
const host = rawHost.startsWith("[") && rawHost.endsWith("]") ? rawHost.slice(1, -1) : rawHost;
if (host === "") return false;
if (isIpLiteral(host)) {
if (isIpv4Literal(host)) return !isIpv4Blocked(host);
return !isIpv6Blocked(host);
if (isIpv4Literal(host)) return !isIpv4Blocked(host, opts);
return !isIpv6Blocked(host, opts);
}
return true; // hostname: resolved + checked at fetch time
}

View File

@@ -36,7 +36,9 @@ import {
isSubscriptionFetchUrlAllowed,
isIpLiteral,
isAnyResolvedAddressBlocked,
type FetchGuardOptions,
} from "./fetchGuard";
import { areLocalProviderUrlsAllowed } from "@/shared/network/outboundUrlGuardPolicy";
import { withRetry } from "./fetchRetry";
import { parseSubscription, redactedNodeSummary, type ParsedSubscription } from "./parse";
@@ -280,13 +282,20 @@ export async function deleteSubscription(id: string): Promise<boolean> {
// ───────────────────────────── Sync + apply ─────────────────────────────
/**
* Refuse to fetch a subscription URL unless it is http/https to a non-internal
* Refuse to fetch a subscription URL unless it is http/https to an allowed
* host. IP literals are checked structurally; hostnames are resolved and the
* resolved addresses are re-checked (fail closed on resolution errors). This
* blocks SSRF to internal services / cloud metadata (169.254.169.254).
* resolved addresses are re-checked (fail closed on resolution errors).
*
* Local-first (#10158): loopback/private fetch targets are ALLOWED when
* `areLocalProviderUrlsAllowed()` is on (default ON — same local-first policy
* already used for provider validation, and consistent with
* `coreEndpoint.ts` already permitting a loopback routing core). Cloud
* metadata / link-local (169.254.0.0/16, incl. 169.254.169.254 IMDS) is
* blocked UNCONDITIONALLY regardless of that flag.
*/
async function assertSafeFetchTarget(url: string): Promise<void> {
if (!isSubscriptionFetchUrlAllowed(url)) {
const guardOpts: FetchGuardOptions = { allowLocal: areLocalProviderUrlsAllowed() };
if (!isSubscriptionFetchUrlAllowed(url, guardOpts)) {
throw new Error("Subscription URL is not allowed (scheme or host blocked)");
}
const host = new URL(url).hostname.toLowerCase();
@@ -299,7 +308,7 @@ async function assertSafeFetchTarget(url: string): Promise<void> {
try {
const dns = await import("node:dns");
const addrs = await dns.promises.lookup(bare, { all: true });
if (isAnyResolvedAddressBlocked(addrs)) {
if (isAnyResolvedAddressBlocked(addrs, guardOpts)) {
throw new Error("Subscription host resolves to a blocked (internal) address");
}
} catch (e) {

View File

@@ -44,28 +44,14 @@ test("(a) strips a single trailing assistant (model) turn for Claude models", as
assert.equal(contents.at(-1)?.role, "user");
});
test("(b) strips a trailing model turn for native Gemini models too (#10104)", async () => {
// Newer Gemini endpoints reject a request ending on a `model` turn with the same
// class of 400 Claude hits via Vertex ("Requests ending with a model turn are not
// supported"), so native Gemini models routed through Antigravity get the same
// guarded strip as the Claude path.
test("(b) does NOT strip a trailing model turn for non-Claude (native Gemini) models", async () => {
const request = await transform("antigravity/gemini-3.1-pro", [
{ role: "user", parts: [{ text: "Hello" }] },
{ role: "model", parts: [{ text: "Hi there" }] },
]);
const contents = request.contents as Array<{ role: string }>;
assert.equal(contents.length, 1);
assert.equal(contents.at(-1)?.role, "user", "transformed native Gemini request must end on user");
});
test("(b2) native Gemini agent tier (gemini-3-flash-agent, #10104) also gets the strip", async () => {
const request = await transform("antigravity/gemini-3-flash-agent", [
{ role: "user", parts: [{ text: "Hello" }] },
{ role: "model", parts: [{ text: "Hi there" }] }, // trailing model turn -> 400 source
]);
const contents = request.contents as Array<{ role: string }>;
assert.equal(contents.length, 1, "trailing model turn should be stripped for native Gemini");
assert.equal(contents.at(-1)?.role, "user", "transformed native Gemini request must end on user");
assert.equal(contents.length, 2);
assert.equal(contents.at(-1)?.role, "model", "native Gemini requests via Antigravity are untouched");
});
test("(c) a Claude conversation already ending on user is unchanged", async () => {

View File

@@ -23,34 +23,68 @@ test("non-http(s) schemes are rejected", () => {
assert.equal(isSubscriptionFetchUrlAllowed("gopher://example.com"), false);
});
test("blocked IPv4 literals are rejected", () => {
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0"]) {
test("local-first (#10158): loopback/private IPv4 literals are ALLOWED by default", () => {
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), true, ip);
}
});
test("cloud-metadata / link-local / unspecified IPv4 literals are ALWAYS blocked", () => {
for (const ip of ["169.254.169.254", "169.254.1.1", "0.0.0.0"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://${ip}/x`), false, ip);
}
});
test("strict mode (allowLocal: false) rejects loopback/private IPv4 literals", () => {
for (const ip of ["127.0.0.1", "10.0.0.5", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0"]) {
assert.equal(
isSubscriptionFetchUrlAllowed(`https://${ip}/x`, { allowLocal: false }),
false,
ip
);
}
});
test("public IPv4 literals are allowed", () => {
assert.equal(isSubscriptionFetchUrlAllowed("https://8.8.8.8/x"), true);
assert.equal(isSubscriptionFetchUrlAllowed("http://1.1.1.1/"), true);
});
test("blocked IPv6 literals are rejected (bracketed)", () => {
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
test("local-first (#10158): loopback/ULA IPv6 literals are ALLOWED by default", () => {
for (const ip of ["::1", "fc00::1", "fd12:3456::1"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), true, ip);
}
});
test("unspecified / link-local IPv6 literals are ALWAYS blocked", () => {
for (const ip of ["::", "fe80::1"]) {
assert.equal(isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`), false, ip);
}
});
test("strict mode (allowLocal: false) rejects loopback/ULA IPv6 literals", () => {
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd12:3456::1"]) {
assert.equal(
isSubscriptionFetchUrlAllowed(`https://[${ip}]/x`, { allowLocal: false }),
false,
ip
);
}
});
test("malformed / empty-host URLs are rejected", () => {
assert.equal(isSubscriptionFetchUrlAllowed("not a url"), false);
assert.equal(isSubscriptionFetchUrlAllowed(""), false);
assert.equal(isSubscriptionFetchUrlAllowed("http://?x"), false); // empty host
});
test("ip-range + literal helpers", () => {
assert.equal(isIpv4Blocked("127.0.0.1"), true);
assert.equal(isIpv4Blocked("169.254.169.254"), true);
test("ip-range + literal helpers (local-first defaults)", () => {
assert.equal(isIpv4Blocked("127.0.0.1"), false); // allowed by default (local-first)
assert.equal(isIpv4Blocked("127.0.0.1", { allowLocal: false }), true);
assert.equal(isIpv4Blocked("169.254.169.254"), true); // always blocked
assert.equal(isIpv4Blocked("8.8.8.8"), false);
assert.equal(isIpv6Blocked("::1"), true);
assert.equal(isIpv6Blocked("::1"), false); // allowed by default (local-first)
assert.equal(isIpv6Blocked("::1", { allowLocal: false }), true);
assert.equal(isIpv6Blocked("2606:4700::1111"), false);
assert.equal(isIpLiteral("127.0.0.1"), true);
assert.equal(isIpLiteral("::1"), true);
@@ -58,14 +92,22 @@ test("ip-range + literal helpers", () => {
assert.deepEqual([...ALLOWED_FETCH_SCHEMES], ["http:", "https:"]);
});
test("multi-record DNS: blocks if ANY resolved address is internal", () => {
// Hostname resolves to a public AND a private address — must be refused
// (closes the first-address-only bypass).
test("multi-record DNS: blocks if ANY resolved address is metadata/link-local (local-first default)", () => {
// A private address alongside a public one is now ALLOWED by default
// (local-first) — only cloud-metadata/link-local addresses stay blocked.
assert.equal(
isAnyResolvedAddressBlocked([
{ address: "8.8.8.8", family: 4 },
{ address: "192.168.1.10", family: 4 },
]),
false
);
// A cloud-metadata address among public records → still blocked.
assert.equal(
isAnyResolvedAddressBlocked([
{ address: "8.8.8.8", family: 4 },
{ address: "169.254.169.254", family: 4 },
]),
true
);
// All public → allowed.
@@ -76,14 +118,45 @@ test("multi-record DNS: blocks if ANY resolved address is internal", () => {
]),
false
);
// A single internal IPv6 among public records → blocked.
// Strict mode (allowLocal: false): a private address is blocked again.
assert.equal(
isAnyResolvedAddressBlocked([
{ address: "2606:4700::1111", family: 6 },
{ address: "fd00::1", family: 6 },
]),
isAnyResolvedAddressBlocked(
[
{ address: "8.8.8.8", family: 4 },
{ address: "192.168.1.10", family: 4 },
],
{ allowLocal: false }
),
true
);
// A single internal IPv6 among public records → blocked in strict mode.
assert.equal(
isAnyResolvedAddressBlocked(
[
{ address: "2606:4700::1111", family: 6 },
{ address: "fd00::1", family: 6 },
],
{ allowLocal: false }
),
true
);
// Empty result set → nothing blocked.
assert.equal(isAnyResolvedAddressBlocked([]), false);
});
// ─────────────────── Regression: #10158 local proxy subscription ───────────────────
// Promoted from the TDD probe (was RED on release/v3.8.50: local http subscription
// URLs were rejected by the strict SSRF guard even though the same feature already
// permits loopback for the routing half — coreEndpoint.ts's ALLOWED_LOCAL_CORE_HOSTS).
test("#10158: local (127.0.0.1) http subscription URL is allowed by default", () => {
assert.equal(
isSubscriptionFetchUrlAllowed("http://127.0.0.1:8080/list"),
true,
"an operator should be able to fetch a proxy list from a local HTTP server"
);
});
test("#10158: IMDS / cloud-metadata pivot stays blocked even with local-first default", () => {
assert.equal(isSubscriptionFetchUrlAllowed("http://169.254.169.254/latest/meta-data/"), false);
});

View File

@@ -3,6 +3,7 @@ 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;
@@ -229,3 +230,57 @@ test("global→rule switch re-evaluates binding: drops global, binds the selecte
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();
}
});