diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 6b88f802a..9624481ff 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -3,6 +3,8 @@ import { createServer } from "http" import { escapeHtml } from "@/util/html" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" +const OAUTH_CALLBACK_HOST = "127.0.0.1" + // Current callback server configuration (may differ from defaults if custom redirectUri is used) let currentPort = OAUTH_CALLBACK_PORT let currentPath = OAUTH_CALLBACK_PATH @@ -162,7 +164,7 @@ export async function ensureRunning(redirectUri?: string): Promise { server = createServer(handleRequest) await new Promise((resolve, reject) => { - server!.listen(currentPort, () => { + server!.listen(currentPort, OAUTH_CALLBACK_HOST, () => { resolve() }) server!.on("error", reject) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index cac714658..552bd26ca 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -1,7 +1,41 @@ import { test, expect, describe, afterEach } from "bun:test" +import { createConnection, createServer as createNetServer } from "net" import { McpOAuthCallback } from "../../src/mcp/oauth-callback" import { parseRedirectUri } from "../../src/mcp/oauth-provider" +async function getFreeLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const probe = createNetServer() + probe.once("error", reject) + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() + probe.close(() => { + if (typeof address === "object" && address) { + resolve(address.port) + return + } + reject(new Error("Could not allocate a loopback port")) + }) + }) + }) +} + +async function canConnect(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = createConnection({ host, port }) + const done = (ok: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolve(ok) + } + + socket.setTimeout(500) + socket.once("connect", () => done(true)) + socket.once("error", () => done(false)) + socket.once("timeout", () => done(false)) + }) +} + describe("parseRedirectUri", () => { test("returns defaults when no URI provided", () => { const result = parseRedirectUri() @@ -69,4 +103,12 @@ describe("McpOAuthCallback.ensureRunning", () => { expect(await response.text()).toContain('
The user denied access
') }) + + test("binds the callback server to IPv4 loopback", async () => { + const port = await getFreeLoopbackPort() + await McpOAuthCallback.ensureRunning(`http://127.0.0.1:${port}/custom/callback`) + + expect(await canConnect("127.0.0.1", port)).toBe(true) + expect(await canConnect("::1", port)).toBe(false) + }) })