Skip to content

Commit 64342c9

Browse files
author
YourName
committed
security: pin provider discovery transports
1 parent 8e221b5 commit 64342c9

15 files changed

Lines changed: 925 additions & 155 deletions

docs-site/src/content/docs/reference/configuration.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,31 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
7575
`max_concurrent_threads_per_session` value under `[features.multi_agent_v2]` in Codex's
7676
`$CODEX_HOME/config.toml`; enable v2 first so that table exists.
7777

78+
## Provider diagnostic outbound safety
79+
80+
The dashboard provider connection test and live model discovery use a bounded GET-only outbound
81+
transport. Without an outbound proxy, opencodex resolves the provider hostname once and connects
82+
only to that validated address. HTTPS keeps the original hostname for Host, SNI, and certificate
83+
verification; certificate verification cannot be disabled by provider config.
84+
85+
When `HTTP_PROXY` or `HTTPS_PROXY` applies, these two operations keep Bun's native fetch so existing
86+
proxy behavior is not silently bypassed. URL/literal checks still run. Successful local DNS answers
87+
are classified, but a local DNS failure is allowed through because proxy-only networks commonly
88+
delegate name resolution to the proxy. The proxy chooses the final route, DNS answer, and peer, so
89+
opencodex logs that this path cannot pin or verify the proxy-selected peer. This is an explicit
90+
security limitation, not equivalent protection against DNS rebinding.
91+
92+
Private/local provider destinations require both `allowPrivateNetwork: true` and a matching
93+
`NO_PROXY` entry whenever an outbound proxy is configured. Loopback entries are added to `NO_PROXY`
94+
automatically. A LAN provider such as `192.168.1.50` must be added explicitly; otherwise connection
95+
tests and model discovery reject it with an actionable message instead of sending it to the proxy.
96+
Metadata and link-local destinations remain blocked even when `allowPrivateNetwork` is enabled.
97+
98+
Both direct and proxied diagnostic paths reject redirects and report a credential-stripped target;
99+
configure the final provider URL directly. Ordinary provider requests, streaming responses, and
100+
retry paths are not migrated in this phase. Their redirect handling and per-hop destination review
101+
remain deferred, so this phase does not close the main-request redirect finding.
102+
78103
## Combos (`config.combos`)
79104

80105
Failover / round-robin aliases live under `combos.<id>` with `targets` (provider + model), optional

src/codex/catalog/provider-fetch.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ import {
3939
targetKey,
4040
} from "../../combos";
4141
import type { NormalizedComboConfig } from "../../combos/types";
42-
import { providerDestinationResolvedError } from "../../lib/destination-policy";
42+
import {
43+
ProviderOutboundPolicyError,
44+
providerOutboundGet,
45+
providerRedirectError,
46+
} from "../../lib/provider-outbound";
4347
import { redactSecretString } from "../../lib/redact";
4448
import upstreamModelsSnapshot from "../data/upstream-models.json";
4549

@@ -322,21 +326,20 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
322326
};
323327
};
324328
try {
325-
const destinationError = await providerDestinationResolvedError(name, {
326-
baseUrl: url,
327-
allowPrivateNetwork: prov.allowPrivateNetwork,
329+
const res = await providerOutboundGet(name, prov, url, {
330+
headers,
331+
signal: AbortSignal.timeout(8000),
328332
});
329-
if (destinationError) {
330-
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
333+
const redirectError = await providerRedirectError(res, url);
334+
if (redirectError) {
335+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
331336
if (shouldLog) {
332337
console.warn(
333-
`[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
338+
`[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`,
334339
);
335340
}
336341
return models;
337342
}
338-
339-
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
340343
if (!res.ok) {
341344
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
342345
if (shouldLog) {
@@ -421,6 +424,15 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
421424
setCached(name, live);
422425
return live;
423426
} catch (error) {
427+
if (error instanceof ProviderOutboundPolicyError) {
428+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
429+
if (shouldLog) {
430+
console.warn(
431+
`[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`,
432+
);
433+
}
434+
return models;
435+
}
424436
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
425437
if (shouldLog) {
426438
console.warn(

src/images/artifacts.ts

Lines changed: 11 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { readdirSync, readFileSync, statSync, unlinkSync, existsSync } from "node:fs";
22
import { mkdir, writeFile } from "node:fs/promises";
3-
import type { IncomingMessage } from "node:http";
4-
import https from "node:https";
5-
import type { RequestOptions } from "node:https";
63
import { basename, join, resolve, sep } from "node:path";
74
import { getConfigDir } from "../config";
85
import { assessUrlDestination, resolvePublicAddresses } from "../lib/destination-policy";
96
import { recordOwnedConfigPath } from "../lib/config-ownership";
7+
import { pinnedHttpGet, type PinnedAddress } from "../lib/pinned-http";
8+
9+
export type { PinnedAddress } from "../lib/pinned-http";
1010

1111
const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024;
1212
const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024;
@@ -40,8 +40,6 @@ export interface ImageBudget {
4040
spent: number;
4141
}
4242

43-
export type PinnedAddress = { address: string; family: number };
44-
4543
/** Test seam / custom transport: must connect to `pinned`, not re-resolve `url`'s hostname. */
4644
export type PinnedDownloadFn = (
4745
url: string,
@@ -282,120 +280,14 @@ export function pinnedHttpsGet(
282280
}
283281
const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES;
284282
const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS;
285-
286-
return new Promise<Response>((resolve, reject) => {
287-
if (signal?.aborted) {
288-
reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
289-
return;
290-
}
291-
292-
let settled = false;
293-
const fail = (err: unknown) => {
294-
try { req.destroy(); } catch { /* ignore */ }
295-
if (settled) return;
296-
settled = true;
297-
reject(err instanceof Error ? err : new Error(String(err)));
298-
};
299-
300-
const optionsHttps: RequestOptions & { servername?: string } = {
301-
protocol: "https:",
302-
hostname: parsed.hostname,
303-
servername: parsed.hostname,
304-
port: parsed.port || 443,
305-
path: `${parsed.pathname}${parsed.search}`,
306-
method: "GET",
307-
headers: { Host: parsed.host },
308-
rejectUnauthorized: options?.rejectUnauthorized,
309-
lookup(_hostname, lookupOptions, callback) {
310-
const opts = typeof lookupOptions === "function" ? undefined : lookupOptions;
311-
const cb = typeof lookupOptions === "function" ? lookupOptions : callback;
312-
if (!cb) return;
313-
// Pin the validated peer — do not call dns.lookup again.
314-
// Honor `{ all: true }` array shape used by some Node/Bun https paths.
315-
if (opts && typeof opts === "object" && "all" in opts && opts.all) {
316-
(cb as (err: NodeJS.ErrnoException | null, addresses: PinnedAddress[]) => void)(
317-
null,
318-
[{ address: pinned.address, family: pinned.family }],
319-
);
320-
return;
321-
}
322-
(cb as (err: NodeJS.ErrnoException | null, address: string, family: 4 | 6) => void)(
323-
null,
324-
pinned.address,
325-
pinned.family as 4 | 6,
326-
);
327-
},
328-
};
329-
330-
const req = https.request(optionsHttps, (res: IncomingMessage) => {
331-
const status = res.statusCode ?? 0;
332-
// Any non-2xx must destroy immediately. Returning a streaming Response for
333-
// 4xx/5xx (or 3xx) lets callers that only check `Response.ok` abandon an
334-
// unread body while the peer keeps sending — a failed-response socket leak.
335-
if (status < 200 || status >= 300) {
336-
try { res.destroy(); } catch { /* ignore */ }
337-
fail(new Error("image download failed: " + status));
338-
return;
339-
}
340-
const headers = new Headers();
341-
for (const [key, value] of Object.entries(res.headers)) {
342-
if (value === undefined || value === null) continue;
343-
if (Array.isArray(value)) {
344-
for (const item of value) headers.append(key, String(item));
345-
} else {
346-
headers.set(key, String(value));
347-
}
348-
}
349-
350-
let received = 0;
351-
const stream = new ReadableStream<Uint8Array>({
352-
start(controller) {
353-
res.setTimeout(idleTimeoutMs, () => {
354-
fail(new Error("image download stalled"));
355-
try { controller.error(new Error("image download stalled")); } catch { /* closed */ }
356-
});
357-
res.on("data", (chunk: Buffer | string) => {
358-
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
359-
received += buf.byteLength;
360-
if (received > maxBytes) {
361-
const err = new Error(`image download exceeds ${maxBytes} byte cap`);
362-
fail(err);
363-
try { controller.error(err); } catch { /* closed */ }
364-
return;
365-
}
366-
try { controller.enqueue(buf); } catch { /* closed */ }
367-
});
368-
res.on("end", () => {
369-
try { controller.close(); } catch { /* closed */ }
370-
});
371-
res.on("error", (err: Error) => {
372-
fail(err);
373-
try { controller.error(err); } catch { /* closed */ }
374-
});
375-
},
376-
cancel() {
377-
req.destroy();
378-
},
379-
});
380-
381-
if (settled) return;
382-
settled = true;
383-
resolve(new Response(stream, { status, headers }));
384-
});
385-
386-
const onAbort = () => {
387-
fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
388-
};
389-
signal?.addEventListener("abort", onAbort, { once: true });
390-
req.setTimeout(idleTimeoutMs, () => {
391-
fail(new Error("image download timed out"));
392-
});
393-
req.on("error", (err) => {
394-
signal?.removeEventListener("abort", onAbort);
395-
fail(err);
396-
});
397-
req.on("close", () => signal?.removeEventListener("abort", onAbort));
398-
req.end();
283+
return pinnedHttpGet(url, pinned, signal, {
284+
maxBytes,
285+
idleTimeoutMs,
286+
rejectUnauthorized: options?.rejectUnauthorized,
287+
context: "image download",
288+
}).then(response => {
289+
if (!response.ok) throw new Error("image download failed: " + response.status);
290+
return response;
399291
});
400292
}
401293

src/lib/destination-policy.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ export interface UrlDestinationAssessment {
204204
detail: string;
205205
}
206206

207+
export class DestinationDnsResolutionError extends Error {
208+
override readonly name = "DestinationDnsResolutionError";
209+
}
210+
207211
/**
208212
* Synchronous literal URL destination assessment — classifies the hostname
209213
* without DNS resolution. Returns null for unparseable URLs.
@@ -213,55 +217,74 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu
213217
}
214218

215219
/**
216-
* Async DNS-resolved URL safety check. Resolves A/AAAA records and rejects
217-
* if any address is loopback, private, link-local, unspecified, or metadata.
218-
* Throws on unsafe destination; returns the validated public addresses on success
219-
* so callers can pin the connect peer and avoid a second, rebindable resolution.
220-
* DNS resolution failures are treated as unsafe (fail-closed).
220+
* Async DNS-resolved URL safety check. By default, rejects every non-public
221+
* address. Provider diagnostics may explicitly admit loopback/private answers;
222+
* metadata, link-local, and unspecified addresses remain unconditionally denied.
223+
* Returns the validated addresses so direct callers can pin the connect peer and
224+
* avoid a second, rebindable resolution. DNS failures remain fail-closed here;
225+
* the provider proxy wrapper alone may recognize that typed failure and degrade.
221226
*/
222-
export async function resolvePublicAddresses(url: string): Promise<{
227+
export async function resolvePublicAddresses(
228+
url: string,
229+
options?: { context?: string; allowPrivateNetwork?: boolean },
230+
): Promise<{
223231
hostname: string;
224232
addresses: { address: string; family: number }[];
233+
privateNetwork: boolean;
225234
}> {
235+
const context = options?.context?.trim() || "image URL";
236+
const privateNetworkAllowed = options?.allowPrivateNetwork === true;
226237
let hostname: string;
227238
try {
228239
hostname = normalizeHostname(new URL(url.trim()).hostname);
229240
} catch {
230-
throw new Error("image URL is not a valid URL");
241+
throw new Error(`${context} is not a valid URL`);
231242
}
232-
if (!hostname) throw new Error("image URL has no hostname");
243+
if (!hostname) throw new Error(`${context} has no hostname`);
233244
const literalAssessment = assessDestination(url);
245+
let privateNetwork = false;
234246
if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") {
235-
throw new Error(`image URL targets ${literalAssessment.detail}`);
247+
const allowedPrivateLiteral = privateNetworkAllowed
248+
&& (literalAssessment.kind === "localhost"
249+
|| literalAssessment.kind === "loopback"
250+
|| literalAssessment.kind === "private");
251+
if (!allowedPrivateLiteral) throw new Error(`${context} targets ${literalAssessment.detail}`);
252+
privateNetwork = true;
236253
}
237254
// Literal public IPs: no DNS round-trip; pin the literal itself.
238255
const literalKind = isIP(hostname);
239256
if (literalKind !== 0) {
240-
return { hostname, addresses: [{ address: hostname, family: literalKind }] };
257+
return { hostname, addresses: [{ address: hostname, family: literalKind }], privateNetwork };
241258
}
242259
let addresses: { address: string; family: number }[];
243260
try {
244261
addresses = await lookup(hostname, { all: true, verbatim: true });
245262
} catch {
246263
// If DNS fails, we can't verify — fail-closed (unlike provider config-time validation,
247264
// this is a runtime fetch to an untrusted URL, so be conservative).
248-
throw new Error(`image URL hostname ${hostname} could not be resolved`);
265+
throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
249266
}
250267
if (addresses.length === 0) {
251-
throw new Error(`image URL hostname ${hostname} could not be resolved`);
268+
throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
252269
}
253-
const publicAddresses: { address: string; family: number }[] = [];
270+
const validatedAddresses: { address: string; family: number }[] = [];
254271
for (const { address, family } of addresses) {
255272
// Prefer classifying from the address string itself — do not trust a mislabeled
256273
// resolver `family` that could skip IPv4/IPv6 private checks.
257274
const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0);
258275
const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
259276
if (!assessment || assessment.kind !== "public") {
260-
throw new Error(`image URL hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
277+
const allowedPrivateAddress = privateNetworkAllowed
278+
&& assessment
279+
&& (assessment.kind === "loopback" || assessment.kind === "private");
280+
if (!allowedPrivateAddress) {
281+
throw new Error(`${context} hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
282+
}
283+
privateNetwork = true;
261284
}
262-
publicAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
285+
validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
263286
}
264-
return { hostname, addresses: publicAddresses };
287+
return { hostname, addresses: validatedAddresses, privateNetwork };
265288
}
266289

267290
/**

0 commit comments

Comments
 (0)