Skip to content

Commit f463e12

Browse files
fix(openai): allow canonical fake-IP addresses on provider PATCH
The ordinary field-mask PATCH resolved the destination without the allowBenchmarkAddresses opt-in that POST and the re-enable path already use for the canonical built-in OpenAI forward provider. Under Clash/Mihomo fake-IP DNS (chatgpt.com → 198.18.0.0/15) the same canonical provider that was created successfully could never be patched: every context-overlay PATCH was rejected with a benchmark-address destination error. Pass the same exception on PATCH, computed the same way (name === "openai" && isCanonicalOpenAiForwardProvider(next)). The exception stays scoped to the exact canonical transport seed: loopback, RFC1918, metadata, and mixed dangerous DNS answers still fail closed, and non-canonical or OpenAI-like custom providers gain nothing.
1 parent 9af3a7b commit f463e12

2 files changed

Lines changed: 150 additions & 1 deletion

File tree

src/server/management/provider-routes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,13 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
752752
if (!canonicalBudgetOnly) {
753753
const serviceTierError = providerServiceTierConfigError(name, next);
754754
if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
755-
const resolvedError = await providerDestinationResolvedError(name, next);
755+
// Same DNS gate as POST and re-enable: the canonical built-in OpenAI forward
756+
// provider may resolve through Clash/Mihomo fake-IP DNS (198.18.0.0/15), so the
757+
// ordinary PATCH must not reject the very same destination the provider was
758+
// created with. Loopback, RFC1918, metadata, and mixed dangerous answers still
759+
// fail closed; nothing else gains the exception.
760+
const allowBenchmarkAddresses = name === "openai" && isCanonicalOpenAiForwardProvider(next);
761+
const resolvedError = await providerDestinationResolvedError(name, next, { allowBenchmarkAddresses });
756762
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
757763
}
758764
} else if (applied.enablingOpenAi) {

tests/management-provider-validation.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2462,6 +2462,149 @@ describe("provider management validation", () => {
24622462
}
24632463
});
24642464

2465+
test("canonical OpenAI PATCH passes allowBenchmarkAddresses into destination resolution", async () => {
2466+
// The ordinary field-mask PATCH resolves the SAME canonical chatgpt.com destination
2467+
// POST and re-enable already admit. Without the benchmark opt-in here, a Clash/Mihomo
2468+
// fake-IP user (chatgpt.com → 198.18.0.0/15) could create the provider but could never
2469+
// patch a context overlay onto it. Loopback/RFC1918/metadata and mixed dangerous
2470+
// answers still fail closed (covered by destination-policy-resolved tests and below).
2471+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
2472+
mkdirSync(TEST_DIR, { recursive: true });
2473+
process.env.OPENCODEX_HOME = TEST_DIR;
2474+
const liveConfig: OcxConfig = {
2475+
port: 0,
2476+
defaultProvider: "openai",
2477+
providers: {
2478+
openai: { ...canonicalDirect },
2479+
},
2480+
};
2481+
const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError")
2482+
.mockResolvedValue(null);
2483+
2484+
try {
2485+
const patch = async (name: string, body: unknown) => {
2486+
const request = new Request(`http://127.0.0.1/api/providers?name=${encodeURIComponent(name)}`, {
2487+
method: "PATCH",
2488+
headers: { "content-type": "application/json" },
2489+
body: JSON.stringify(body),
2490+
});
2491+
return handleManagementAPI(request, new URL(request.url), liveConfig, {
2492+
createManagementConvergeCodex: catalogConvergenceFactory(),
2493+
});
2494+
};
2495+
2496+
const canonical = await patch("openai", { modelContextWindows: { "gpt-5.6-luna": 900000 } });
2497+
expect(canonical?.status).toBe(200);
2498+
expect(resolvedError).toHaveBeenCalledWith(
2499+
"openai",
2500+
expect.objectContaining({ baseUrl: canonicalDirect.baseUrl }),
2501+
{ allowBenchmarkAddresses: true },
2502+
);
2503+
} finally {
2504+
resolvedError.mockRestore();
2505+
}
2506+
});
2507+
2508+
test("PATCH destination benchmark exception stays scoped to the canonical openai row", async () => {
2509+
// A non-canonical openai row and any OpenAI-LOOKING custom provider must not inherit
2510+
// the fake-IP exception: their PATCHes still fail closed on benchmark answers.
2511+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
2512+
mkdirSync(TEST_DIR, { recursive: true });
2513+
process.env.OPENCODEX_HOME = TEST_DIR;
2514+
const liveConfig: OcxConfig = {
2515+
port: 0,
2516+
defaultProvider: "openai",
2517+
providers: {
2518+
openai: { ...canonicalDirect },
2519+
mirror: {
2520+
adapter: "openai-chat",
2521+
baseUrl: "https://mirror.example.test/v1",
2522+
apiKey: "sk-secret-value",
2523+
},
2524+
"openai-proxy": {
2525+
adapter: "openai-chat",
2526+
baseUrl: "https://mirror.example.test/v1",
2527+
apiKey: "sk-secret-value",
2528+
},
2529+
},
2530+
};
2531+
const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError")
2532+
.mockResolvedValue(
2533+
"baseUrl hostname mirror.example.test resolves to a benchmark address (198.18.0.30); set allowPrivateNetwork:true only for intentionally local/self-hosted providers",
2534+
);
2535+
2536+
try {
2537+
const patch = async (name: string, body: unknown) => {
2538+
const request = new Request(`http://127.0.0.1/api/providers?name=${encodeURIComponent(name)}`, {
2539+
method: "PATCH",
2540+
headers: { "content-type": "application/json" },
2541+
body: JSON.stringify(body),
2542+
});
2543+
return handleManagementAPI(request, new URL(request.url), liveConfig, {
2544+
createManagementConvergeCodex: catalogConvergenceFactory(),
2545+
});
2546+
};
2547+
2548+
const custom = await patch("mirror", { defaultModel: "gpt-x" });
2549+
expect(custom?.status).toBe(400);
2550+
expect(resolvedError).toHaveBeenCalledWith(
2551+
"mirror",
2552+
expect.anything(),
2553+
{ allowBenchmarkAddresses: false },
2554+
);
2555+
2556+
// Non-canonical row named "openai"-adjacent: no exception either.
2557+
const openaiProxy = await patch("openai-proxy", { defaultModel: "gpt-x" });
2558+
expect(openaiProxy?.status).toBe(400);
2559+
expect(resolvedError).toHaveBeenCalledWith(
2560+
"openai-proxy",
2561+
expect.anything(),
2562+
{ allowBenchmarkAddresses: false },
2563+
);
2564+
} finally {
2565+
resolvedError.mockRestore();
2566+
}
2567+
});
2568+
2569+
test("canonical OpenAI PATCH still rejects non-benchmark private destination answers", async () => {
2570+
// The benchmark opt-in must not relax the rest of the SSRF guard: if the probe
2571+
// classifies the canonical destination as loopback/private/metadata, the PATCH fails.
2572+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
2573+
mkdirSync(TEST_DIR, { recursive: true });
2574+
process.env.OPENCODEX_HOME = TEST_DIR;
2575+
const liveConfig: OcxConfig = {
2576+
port: 0,
2577+
defaultProvider: "openai",
2578+
providers: {
2579+
openai: { ...canonicalDirect },
2580+
},
2581+
};
2582+
const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError")
2583+
.mockResolvedValue("baseUrl hostname chatgpt.com resolves to a loopback address (127.0.0.1); set allowPrivateNetwork:true only for intentionally local/self-hosted providers");
2584+
2585+
try {
2586+
const request = new Request("http://127.0.0.1/api/providers?name=openai", {
2587+
method: "PATCH",
2588+
headers: { "content-type": "application/json" },
2589+
body: JSON.stringify({ modelContextWindows: { "gpt-5.6-luna": 900000 } }),
2590+
});
2591+
const response = await handleManagementAPI(request, new URL(request.url), liveConfig, {
2592+
createManagementConvergeCodex: catalogConvergenceFactory(),
2593+
});
2594+
expect(response?.status).toBe(400);
2595+
expect(await response?.json()).toMatchObject({
2596+
error: expect.stringContaining("loopback address"),
2597+
});
2598+
expect(resolvedError).toHaveBeenCalledWith(
2599+
"openai",
2600+
expect.anything(),
2601+
{ allowBenchmarkAddresses: true },
2602+
);
2603+
} finally {
2604+
resolvedError.mockRestore();
2605+
}
2606+
});
2607+
24652608
test("disabled-only PATCH cannot re-enable a noncanonical openai row unchanged", async () => {
24662609
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
24672610
mkdirSync(TEST_DIR, { recursive: true });

0 commit comments

Comments
 (0)