mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(openai): allow fake-IP OAuth token exchange (#110096)
Keep the OpenAI OAuth token endpoint usable behind fake-IP proxy DNS while retaining exact-host SSRF protection for redirects and unrelated hosts. Co-authored-by: Abner Shang <75654486+abnershang@users.noreply.github.com>
This commit is contained in:
co-authored by
Abner Shang
parent
79c7569fa6
commit
84ff010eba
@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
|
||||
"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();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user