fix(proxy): never route loopback destinations through the proxy (#951) (#963)

* fix(proxy): never route loopback destinations through the proxy (#951)

The issue reports socks5h being treated as socks5, but the SOCKS path
already sends the destination domain to the proxy for every scheme (the
socksHostnameLookup hook from #630), so the h suffix was never dropped.

The real cause is in the Tor log the reporter included: the app was
giving Tor an IP address to port 11434 — Ollama on 127.0.0.1. Loopback
destinations were being routed through the proxy at all. A proxy cannot
reach your own 127.0.0.1, and because it is an IP literal the SOCKS
agent must send it as ATYP 0x01 (an IP) no matter what the socks5h
suffix promises — exactly what triggers Tor's 'giving Tor only an IP
address' warning and the connection refusal.

shouldBypassProxy now treats loopback (127.0.0.0/8, ::1, 0.0.0.0) and
localhost as direct, on both the global and per-key proxy paths. Public
destinations are unaffected.

* fix(proxy): extend the direct-route bypass to LAN destinations (#951)

The loopback bypass fixed 127.0.0.1, but the same failure hits the other
half of the documented local use case: url-guard's own policy note says
this app exists to point at llama.cpp / Ollama / LM Studio "on localhost
or the LAN", and a remote proxy has no route to 192.168.1.20 either —
while the IP literal still makes Tor log "giving Tor only an IP address"
and refuse the connection.

shouldBypassProxy now routes loopback AND private/LAN destinations
(RFC1918, ULA, CGNAT) direct, by reusing url-guard's classification
instead of a second copy of it: isLoopbackOrPrivateUrl grows a hostname
half, isLoopbackOrPrivateHostname, which the proxy router calls with the
hostname it already parsed for NO_PROXY. That also picks up the
trailing-dot FQDN spelling (`localhost.`), which resolves like the bare
name but matched neither `=== 'localhost'` nor `.endsWith('.localhost')`
and was being proxied as if it were a public host. The URL is now parsed
once for both checks rather than twice in two try/catch blocks.

FREEAPI_PROXY_LOCAL_DESTINATIONS=true opts back in, for the one setup
where proxying a local address is the point: an `ssh -D` dynamic tunnel,
where http://127.0.0.1:11434 through the SOCKS proxy is meant to reach
the REMOTE host's Ollama. Documented in .env.example next to NO_PROXY.

Tests cover ::1 (bare and bracketed), 127.0.0.2, 0.0.0.0, a *.localhost
subdomain, `localhost.`, 192.168.1.20 / 10.0.0.5 / 172.16.4.2, an IPv6
ULA, the opt-out env var in both directions, and a public host still
riding the proxy. clearProxyEnv also clears
FREEAPI_BLOCK_PRIVATE_PROVIDER_URLS, which an operator machine may
export — the local cases use platform 'custom', so it re-runs the SSRF
guard and failed them for the wrong reason.

---------

Co-authored-by: tashdroid <319142293+tashdroid@users.noreply.github.com>
This commit is contained in:
tashdroid
2026-08-22 01:33:26 +01:00
committed by GitHub
parent 3503f915f6
commit fe7744c027
5 changed files with 245 additions and 38 deletions
+10
View File
@@ -83,6 +83,16 @@ PORT=3001
# proxy entirely.
# NO_PROXY=localhost,127.0.0.1,.internal.corp
# Local and LAN destinations (localhost, 127.0.0.0/8, ::1, 0.0.0.0, RFC1918 /
# ULA / CGNAT addresses) always bypass the proxy — a remote proxy has no route
# back to your own machine, and an IP literal makes Tor log "giving Tor only an
# IP address" and refuse the connection. That is the right default for a local
# Ollama / llama.cpp / LM Studio endpoint.
# Set this to true only if proxying them is the point — e.g. an `ssh -D`
# dynamic tunnel, where http://127.0.0.1:11434 through the SOCKS proxy is meant
# to reach the REMOTE host's Ollama.
# FREEAPI_PROXY_LOCAL_DESTINATIONS=true
# Per-provider daily request cap, named PROVIDER_DAILY_REQUEST_CAP_<PLATFORM>
# (platform name upper-cased). Overrides the built-in default for that provider;
# set to 0 to disable the cap. Example — cap OpenRouter at 50 requests/day:
+143 -16
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import http from 'http';
import https from 'https';
import {
applyProxyUrl,
@@ -23,7 +24,19 @@ import {
// Every env var the proxy config reads, in both the upper- and lower-case
// spellings the convention allows. Cleared around each test so a developer
// machine that genuinely sits behind a corporate proxy doesn't fail the suite.
const PROXY_ENV_VARS = ['PROXY_URL', 'ALL_PROXY', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'];
// FREEAPI_BLOCK_PRIVATE_PROVIDER_URLS is not a proxy knob, but the local/LAN
// cases below call proxyFetch with platform 'custom', which re-runs the SSRF
// guard — an operator machine that exports it would fail them for the wrong
// reason.
const PROXY_ENV_VARS = [
'PROXY_URL',
'ALL_PROXY',
'HTTPS_PROXY',
'HTTP_PROXY',
'NO_PROXY',
'FREEAPI_PROXY_LOCAL_DESTINATIONS',
'FREEAPI_BLOCK_PRIVATE_PROVIDER_URLS',
];
function clearProxyEnv(): void {
for (const name of PROXY_ENV_VARS) {
@@ -117,22 +130,31 @@ describe('SOCKS scheme detection (#630)', () => {
// with a SocksProxyAgent. Stubbing https.request lets us assert *which* agent
// the dispatcher picked (and that socks5h parsed into a real SOCKS5 agent)
// without opening a socket.
const fakeRequest = ((_opts: any, cb: any) => {
const req = new EventEmitter() as any;
req.write = () => {};
req.destroy = () => {};
req.end = () => {
const res = new EventEmitter() as any;
res.statusCode = 200;
res.statusMessage = 'OK';
res.headers = {};
res.destroy = () => {};
cb(res);
setImmediate(() => res.emit('end'));
};
return req;
}) as any;
function stubHttpsRequest() {
return vi.spyOn(https, 'request').mockImplementation(((_opts: any, cb: any) => {
const req = new EventEmitter() as any;
req.write = () => {};
req.destroy = () => {};
req.end = () => {
const res = new EventEmitter() as any;
res.statusCode = 200;
res.statusMessage = 'OK';
res.headers = {};
res.destroy = () => {};
cb(res);
setImmediate(() => res.emit('end'));
};
return req;
}) as any);
return vi.spyOn(https, 'request').mockImplementation(fakeRequest);
}
// A plain `http://` destination rides http.request, not https.request — the
// local-endpoint cases (#951) are all http, so both transports need stubbing
// before a test can claim nothing reached the wire.
function stubHttpRequest() {
return vi.spyOn(http, 'request').mockImplementation(fakeRequest);
}
describe('proxyFetch dispatcher selection for SOCKS schemes (#630)', () => {
@@ -467,6 +489,111 @@ describe('proxyFetch routing', () => {
});
});
// #951: a local destination — Ollama/llama.cpp/LM Studio on 127.0.0.1, or on
// the LAN at 192.168.1.20 — is unreachable through a remote proxy, and because
// an IP literal must go on the wire as ATYP 0x01 (an IP) regardless of the
// `socks5h` suffix, it is exactly what makes Tor log "giving Tor only an IP
// address" and may get the connection refused. Loopback and private/LAN
// addresses therefore always bypass the proxy, unless the operator opts out
// with FREEAPI_PROXY_LOCAL_DESTINATIONS.
describe('local and LAN destinations bypass the proxy (#951)', () => {
/** Run one request through the SOCKS-configured proxy and report the route. */
const routeOf = async (url: string): Promise<'direct' | 'proxied'> => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(okResponse());
const httpsSpy = stubHttpsRequest();
const httpSpy = stubHttpRequest();
await proxyFetch(url, { method: 'POST' }, 'custom');
// A SOCKS route never touches fetch(): it goes out through http/https.request
// with a SocksProxyAgent attached.
if (httpsSpy.mock.calls.length + httpSpy.mock.calls.length > 0) {
expect(fetchSpy).not.toHaveBeenCalled();
return 'proxied';
}
expect(fetchSpy).toHaveBeenCalledTimes(1);
// Direct means no dispatcher was injected either.
expect((fetchSpy.mock.calls[0]?.[1] as any)?.dispatcher).toBeUndefined();
return 'direct';
};
beforeEach(() => {
applyProxyUrl('socks5h://127.0.0.1:9050');
});
const directCases: Array<[string, string]> = [
['IPv4 loopback', 'http://127.0.0.1:11434/api/chat'],
['the 127/8 range beyond .0.1', 'http://127.0.0.2:11434/api/chat'],
['"this host" 0.0.0.0', 'http://0.0.0.0:11434/api/chat'],
['bracketed IPv6 loopback', 'http://[::1]:11434/api/chat'],
['the localhost name', 'http://localhost:11434/api/chat'],
['a *.localhost subdomain', 'http://ollama.localhost:11434/api/chat'],
['the trailing-dot FQDN form of localhost', 'http://localhost.:11434/api/chat'],
['an RFC1918 LAN address', 'http://192.168.1.20:11434/api/chat'],
['a 10/8 LAN address', 'http://10.0.0.5:11434/api/chat'],
['a 172.16/12 LAN address', 'http://172.16.4.2:11434/api/chat'],
['an IPv6 ULA address', 'http://[fd12:3456::1]:11434/api/chat'],
];
for (const [label, url] of directCases) {
it(`sends ${label} direct`, async () => {
expect(await routeOf(url)).toBe('direct');
});
}
it('still routes a public destination through the SOCKS proxy', async () => {
const fetchSpy = vi.spyOn(global, 'fetch');
const reqSpy = stubHttpsRequest();
await proxyFetch('https://api.openai.com/v1/models', { method: 'GET' }, 'openai');
// Public host still goes through the SOCKS agent (port 9050).
expect(fetchSpy).not.toHaveBeenCalled();
expect((reqSpy.mock.calls[0][0] as any).agent?.proxy?.port).toBe(9050);
});
it('applies to the per-key proxy path too', async () => {
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(okResponse());
const httpsSpy = stubHttpsRequest();
const httpSpy = stubHttpRequest();
await withKeyProxy('socks5h://127.0.0.1:9051', () =>
proxyFetch('http://192.168.1.20:11434/api/chat', { method: 'POST' }, 'custom'));
expect(httpsSpy).not.toHaveBeenCalled();
expect(httpSpy).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect((fetchSpy.mock.calls[0]?.[1] as any)?.dispatcher).toBeUndefined();
});
// The `ssh -D` case: the tunnel's far end is where 127.0.0.1:11434 is meant
// to resolve, so the operator can force local destinations back through it.
describe('FREEAPI_PROXY_LOCAL_DESTINATIONS opt-out', () => {
it('routes loopback through the proxy when set', async () => {
process.env.FREEAPI_PROXY_LOCAL_DESTINATIONS = 'true';
applyProxyUrl('socks5h://127.0.0.1:9050');
expect(await routeOf('http://127.0.0.1:11434/api/chat')).toBe('proxied');
});
it('routes a LAN address through the proxy when set', async () => {
process.env.FREEAPI_PROXY_LOCAL_DESTINATIONS = '1';
applyProxyUrl('socks5h://127.0.0.1:9050');
expect(await routeOf('http://192.168.1.20:11434/api/chat')).toBe('proxied');
});
it('is ignored when set to a non-truthy value', async () => {
process.env.FREEAPI_PROXY_LOCAL_DESTINATIONS = 'false';
applyProxyUrl('socks5h://127.0.0.1:9050');
expect(await routeOf('http://127.0.0.1:11434/api/chat')).toBe('direct');
});
it('still honours the global off switch', async () => {
process.env.FREEAPI_PROXY_LOCAL_DESTINATIONS = 'true';
applyProxyUrl('socks5h://127.0.0.1:9050');
applyProxyEnabled(false);
expect(await routeOf('http://127.0.0.1:11434/api/chat')).toBe('direct');
});
});
});
// SSRF guard, request-time half (#440). The save-time check validates the
// literal base_url, but fetch()'s default redirect: 'follow' would re-request
// a 3xx Location target with no re-validation — a public base_url answering
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, afterEach } from 'vitest';
import { classifyIp, assessProviderUrl, isLoopbackOrPrivateUrl } from '../../lib/url-guard.js';
import { classifyIp, assessProviderUrl, isLoopbackOrPrivateUrl, isLoopbackOrPrivateHostname } from '../../lib/url-guard.js';
// SSRF guard for user-supplied custom-provider base URLs (#440). Metadata and
// link-local targets must never be reachable; loopback/private stay allowed by
@@ -170,4 +170,34 @@ describe('isLoopbackOrPrivateUrl (#592 local-endpoint cooldown exemption)', () =
expect(isLoopbackOrPrivateUrl('')).toBe(false);
expect(isLoopbackOrPrivateUrl('not a url')).toBe(false);
});
// The FQDN spelling resolves exactly like the bare name, so it must not slip
// through as "some public host" (#951 — it would then get proxied).
it('true for the trailing-dot FQDN spelling of localhost', () => {
expect(isLoopbackOrPrivateUrl('http://localhost./v1')).toBe(true);
expect(isLoopbackOrPrivateUrl('http://ollama.localhost./v1')).toBe(true);
});
});
describe('isLoopbackOrPrivateHostname (hostname half, used by the proxy router)', () => {
it('matches the same classes as the URL form', () => {
expect(isLoopbackOrPrivateHostname('127.0.0.2')).toBe(true);
expect(isLoopbackOrPrivateHostname('0.0.0.0')).toBe(true);
expect(isLoopbackOrPrivateHostname('[::1]')).toBe(true);
expect(isLoopbackOrPrivateHostname('::1')).toBe(true);
expect(isLoopbackOrPrivateHostname('LocalHost')).toBe(true);
expect(isLoopbackOrPrivateHostname('localhost.')).toBe(true);
expect(isLoopbackOrPrivateHostname('ollama.localhost')).toBe(true);
expect(isLoopbackOrPrivateHostname('192.168.1.20')).toBe(true);
expect(isLoopbackOrPrivateHostname('10.0.0.5')).toBe(true);
expect(isLoopbackOrPrivateHostname('[fd12:3456::1]')).toBe(true);
});
it('false for public hosts and names it cannot decide without DNS', () => {
expect(isLoopbackOrPrivateHostname('api.openai.com')).toBe(false);
expect(isLoopbackOrPrivateHostname('203.0.113.10')).toBe(false);
expect(isLoopbackOrPrivateHostname('notlocalhost')).toBe(false);
expect(isLoopbackOrPrivateHostname('my-lan-box.local')).toBe(false);
expect(isLoopbackOrPrivateHostname('')).toBe(false);
});
});
+40 -14
View File
@@ -2,7 +2,7 @@ import http from 'http';
import https from 'https';
import { AsyncLocalStorage } from 'node:async_hooks';
import { getSetting } from '../db/index.js';
import { assertProviderUrlAllowed } from './url-guard.js';
import { assertProviderUrlAllowed, isLoopbackOrPrivateHostname } from './url-guard.js';
// #590 (per-key proxy): the SAME provider may be reached through different
// exit IPs per key (geo-ban / risk-control avoidance). Providers are process
@@ -132,6 +132,8 @@ let _proxyUrl = '';
let _proxyEnabled = true;
let _bypassPlatforms = new Set<string>();
let _noProxyRules: string[] = [];
// Escape hatch for the `ssh -D` tunnel case — see shouldBypassProxy.
let _proxyLocalDestinations = false;
let _initialized = false;
// Cache.
@@ -179,12 +181,16 @@ export function applyProxyUrl(dbValue: string): void {
const { url, source } = resolveProxySource(dbValue);
_proxyUrl = url;
_noProxyRules = parseNoProxy(readEnv('NO_PROXY'));
_proxyLocalDestinations = /^(1|true|yes)$/i.test(readEnv('FREEAPI_PROXY_LOCAL_DESTINATIONS'));
cached = null;
if (_proxyUrl) {
console.log(`[proxy] Configured → ${redactProxyUrl(_proxyUrl)} (source: ${source})`);
if (_noProxyRules.length > 0) {
console.log(`[proxy] NO_PROXY direct for: ${_noProxyRules.join(', ')}`);
}
if (_proxyLocalDestinations) {
console.log('[proxy] FREEAPI_PROXY_LOCAL_DESTINATIONS is set — localhost/LAN destinations go through the proxy too.');
}
} else {
console.log('[proxy] Not configured — outbound requests go direct.');
}
@@ -246,18 +252,37 @@ export function getNoProxyRules(): string[] {
/**
* Returns true when a request should NOT use the proxy.
* True when: proxy is disabled globally, the platform is in the bypass list,
* or the upstream host is covered by NO_PROXY.
* the upstream host is covered by NO_PROXY, or the upstream is a local/LAN
* destination (#951 — see below).
*
* A loopback (127.0.0.0/8, ::1, 0.0.0.0, `localhost`) or private/LAN
* (RFC1918, ULA, CGNAT) destination is unreachable through a remote proxy:
* that proxy has no route to your own 127.0.0.1 and, on any network but
* yours, none to 192.168.1.20 either. Routing it there is never useful and,
* for SOCKS, actively harmful: an IP literal must go on the wire as ATYP 0x01
* (an IP) no matter what the `socks5h` suffix promises, so Tor logs "giving
* Tor only an IP address" and may refuse the connection. The
* Ollama/llama.cpp/LM Studio case — the app's primary documented local use,
* "on localhost or the LAN" — is exactly this.
*
* FREEAPI_PROXY_LOCAL_DESTINATIONS=true opts out, for the one setup where
* proxying a local address IS the point: an `ssh -D` dynamic tunnel, where
* http://127.0.0.1:11434 sent through the SOCKS proxy resolves at the far end
* and reaches the REMOTE host's Ollama.
*/
function shouldBypassProxy(url: string, platform?: string): boolean {
if (!_proxyEnabled) return true;
if (platform && _bypassPlatforms.has(platform.toLowerCase())) return true;
if (_noProxyRules.length > 0) {
try {
if (noProxyMatches(new URL(url).hostname)) return true;
} catch {
// Unparseable URL — leave the routing decision to the caller/fetch.
}
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
// Unparseable URL — leave the routing decision to the caller/fetch.
return false;
}
if (_noProxyRules.length > 0 && noProxyMatches(hostname)) return true;
if (!_proxyLocalDestinations && isLoopbackOrPrivateHostname(hostname)) return true;
return false;
}
@@ -600,10 +625,10 @@ async function dispatchFetch(
const perKeyUrl = perKeyProxyStore.getStore() ?? '';
if (perKeyUrl) {
// Every bypass still applies, unchanged: the global on/off switch, the
// per-platform bypass list, and NO_PROXY. A per-key override says WHICH
// proxy to use, not that this request must be proxied — an operator who
// turned proxying off, or listed the upstream in NO_PROXY, still gets a
// direct connection.
// per-platform bypass list, NO_PROXY, and local/LAN destinations. A
// per-key override says WHICH proxy to use, not that this request must be
// proxied — an operator who turned proxying off, listed the upstream in
// NO_PROXY, or points at a local box still gets a direct connection.
if (!shouldBypassProxy(url, platform)) {
const resolved = await resolvePerKeyDispatcher(perKeyUrl);
if (resolved) {
@@ -616,8 +641,9 @@ async function dispatchFetch(
// Per-key proxy failed to build → fall through to the global/direct path.
}
// Bypass check: disabled globally, this platform is exempt, or the upstream
// host is listed in NO_PROXY.
// Bypass check: disabled globally, this platform is exempt, the upstream
// host is listed in NO_PROXY, or it is a local/LAN destination no proxy can
// reach (#951).
if (shouldBypassProxy(url, platform)) {
return fetch(url, init);
}
+21 -7
View File
@@ -131,6 +131,26 @@ export function classifyIp(ip: string): AddressClass {
return 'public';
}
/**
* Hostname half of {@link isLoopbackOrPrivateUrl}: true when a bare hostname
* names THIS machine or the LAN. Callers that already parsed the URL (the
* proxy router, which needs the hostname for NO_PROXY anyway) use this
* directly so the URL is parsed once.
*
* Normalises the two spellings a URL hostname can arrive in: IPv6 literals are
* bracketed (`[::1]`), and the FQDN form carries a trailing dot (`localhost.`
* — a real, resolvable spelling that must not slip past as a public name).
*/
export function isLoopbackOrPrivateHostname(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
if (host === 'localhost' || host.endsWith('.localhost')) return true;
if (net.isIP(host)) {
const cls = classifyIp(host);
return cls === 'loopback' || cls === 'private';
}
return false;
}
/**
* Synchronous locality check for a stored provider base_url: true when the URL
* points at THIS machine or the LAN (loopback, RFC1918/ULA private, 'localhost').
@@ -151,13 +171,7 @@ export function isLoopbackOrPrivateUrl(rawUrl: string | null | undefined): boole
} catch {
return false;
}
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
if (net.isIP(hostname)) {
const cls = classifyIp(hostname);
return cls === 'loopback' || cls === 'private';
}
return false;
return isLoopbackOrPrivateHostname(url.hostname);
}
export interface UrlAssessment {