diff --git a/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts b/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts index 5eefadc5419c..73164b3558e2 100644 --- a/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts +++ b/extensions/openai/openai-chatgpt-oauth-flow.runtime.test.ts @@ -7,10 +7,21 @@ const ssrfMocks = vi.hoisted(() => ({ fetchWithSsrFGuard: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/ssrf-runtime", + ); + return { + ...actual, + fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard, + }; +}); +import { + resolvePinnedHostnameWithPolicy, + type LookupFn, + type SsrFPolicy, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { createOpenAIAuthorizationFlow, resolveOpenAICallbackHost, @@ -65,6 +76,36 @@ function mockTokenResponseText(body: string, status = 200): void { }); } +function mockFakeIpTokenResponse(params: { address: string; family: 4 | 6 }): void { + ssrfMocks.fetchWithSsrFGuard.mockImplementationOnce( + async ({ policy }: { policy?: SsrFPolicy }) => { + const lookupFn = vi.fn(async () => [params]) as unknown as LookupFn; + const pinned = await resolvePinnedHostnameWithPolicy("auth.openai.com", { + lookupFn, + policy, + }); + + expect(pinned.addresses).toEqual([params.address]); + await expect( + resolvePinnedHostnameWithPolicy("redirect.example.com", { lookupFn, policy }), + ).rejects.toThrow("Blocked hostname (not in allowlist)"); + expect(lookupFn).toHaveBeenCalledOnce(); + + return { + response: new Response( + JSON.stringify({ + access_token: "test-access-token", + refresh_token: "test-refresh-token", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + release: vi.fn(async () => undefined), + }; + }, + ); +} + afterEach(() => { ssrfMocks.fetchWithSsrFGuard.mockReset(); vi.unstubAllGlobals(); @@ -230,6 +271,40 @@ describe("OpenAI Codex OAuth flow", () => { }); }); + it.each([ + { operation: "authorization-code exchange", address: "198.18.0.42", family: 4 as const }, + { operation: "refresh-token exchange", address: "fc00::42", family: 6 as const }, + ])( + "allows fake-IP DNS for the OpenAI OAuth $operation", + async ({ operation, address, family }) => { + mockFakeIpTokenResponse({ address, family }); + + const result = + operation === "authorization-code exchange" + ? await exchangeOpenAIAuthorizationCode( + "code", + "verifier", + resolveOpenAIRedirectUri("localhost"), + ) + : await refreshOpenAIAccessToken("old-refresh-token"); + + expect(result).toMatchObject({ + type: "success", + access: "test-access-token", + refresh: "test-refresh-token", + }); + expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + policy: { + allowRfc2544BenchmarkRange: true, + allowIpv6UniqueLocalRange: true, + hostnameAllowlist: ["auth.openai.com"], + }, + }), + ); + }, + ); + it("cancels token exchange requests with the caller signal", async () => { const controller = new AbortController(); controller.abort(); diff --git a/extensions/openai/openai-chatgpt-oauth-token.runtime.ts b/extensions/openai/openai-chatgpt-oauth-token.runtime.ts index f13aa432f58c..6013407dc545 100644 --- a/extensions/openai/openai-chatgpt-oauth-token.runtime.ts +++ b/extensions/openai/openai-chatgpt-oauth-token.runtime.ts @@ -3,11 +3,16 @@ import { resolveOAuthTokenLifetimeMs, } from "openclaw/plugin-sdk/provider-oauth-runtime"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { throwIfOAuthLoginAborted } from "./openai-chatgpt-oauth-abort.runtime.js"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const OAUTH_TOKEN_SSRF_POLICY = { + allowRfc2544BenchmarkRange: true, + allowIpv6UniqueLocalRange: true, + hostnameAllowlist: ["auth.openai.com"], +} satisfies SsrFPolicy; const TOKEN_REQUEST_TIMEOUT_MS = 30_000; const OAUTH_TOKEN_RESPONSE_BODY_LIMIT_BYTES = 1 * 1024 * 1024; @@ -61,6 +66,9 @@ async function postTokenForm( throwIfOAuthLoginAborted(options.signal); const { response, release } = await fetchWithSsrFGuard({ url: TOKEN_URL, + // Fake-IP proxies map public hosts into these ranges. The exact-host allowlist + // keeps redirects and every other hostname fail-closed. + policy: OAUTH_TOKEN_SSRF_POLICY, init: { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },