diff --git a/bun.lock b/bun.lock index 6a066555bb6..740abb79909 100644 --- a/bun.lock +++ b/bun.lock @@ -235,6 +235,7 @@ "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tsconfig/node22": "22.0.2", + "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "openai": "5.11.0", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 739921402e7..2848ac685b5 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -11,6 +11,7 @@ "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tsconfig/node22": "22.0.2", + "@types/bun": "catalog:", "@types/node": "catalog:", "openai": "5.11.0", "typescript": "catalog:", diff --git a/packages/console/function/src/auth-redirect.test.ts b/packages/console/function/src/auth-redirect.test.ts new file mode 100644 index 00000000000..b3919dbfacb --- /dev/null +++ b/packages/console/function/src/auth-redirect.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { isAllowedAuthorizationRedirect } from "./auth-redirect" + +describe("authorization redirect validation", () => { + test("allows registered OpenCode callbacks", () => { + expect(isAllowedAuthorizationRedirect("app", "https://opencode.ai/auth/callback")).toBe(true) + expect(isAllowedAuthorizationRedirect("app", "https://dev.opencode.ai/auth/callback")).toBe(true) + expect(isAllowedAuthorizationRedirect("app", "http://localhost:3000/auth/callback")).toBe(true) + expect(isAllowedAuthorizationRedirect("app", "http://127.0.0.1:3000/auth/callback")).toBe(true) + }) + + test("rejects unregistered clients and external redirects", () => { + expect(isAllowedAuthorizationRedirect("other", "https://opencode.ai/auth/callback")).toBe(false) + expect(isAllowedAuthorizationRedirect("app", "https://evil.example/callback")).toBe(false) + expect(isAllowedAuthorizationRedirect("app", "https://opencode.ai.evil.example/callback")).toBe(false) + expect(isAllowedAuthorizationRedirect("app", "javascript:alert(1)")).toBe(false) + }) +}) diff --git a/packages/console/function/src/auth-redirect.ts b/packages/console/function/src/auth-redirect.ts new file mode 100644 index 00000000000..71203f930d5 --- /dev/null +++ b/packages/console/function/src/auth-redirect.ts @@ -0,0 +1,18 @@ +export const isAllowedAuthorizationRedirect = (clientID: string, redirectURI: string) => { + if (clientID !== "app") return false + const redirect = (() => { + try { + return new URL(redirectURI) + } catch { + return undefined + } + })() + if (redirect === undefined) return false + if (redirect.hostname === "localhost" || redirect.hostname === "127.0.0.1") { + return redirect.protocol === "http:" || redirect.protocol === "https:" + } + return ( + redirect.protocol === "https:" && + (redirect.hostname === "opencode.ai" || redirect.hostname.endsWith(".opencode.ai")) + ) +} diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 6d56b967060..457ccc571d5 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -17,6 +17,7 @@ import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.j import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js" import { Identifier } from "@opencode-ai/console-core/identifier.js" +import { isAllowedAuthorizationRedirect } from "./auth-redirect.js" type Env = { AuthStorage: KVNamespace @@ -41,6 +42,17 @@ const MY_THEME: Theme = { export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { + const requestURL = new URL(request.url) + if (requestURL.pathname === "/authorize") { + const redirectURI = requestURL.searchParams.get("redirect_uri") + if ( + redirectURI !== null && + !isAllowedAuthorizationRedirect(requestURL.searchParams.get("client_id") ?? "", redirectURI) + ) { + return new Response("Unauthorized client", { status: 400 }) + } + } + const result = await issuer({ theme: MY_THEME, providers: { @@ -102,6 +114,7 @@ export default { namespace: env.AuthStorage, }), subjects, + allow: ({ clientID, redirectURI }) => Promise.resolve(isAllowedAuthorizationRedirect(clientID, redirectURI)), async success(ctx, response) { console.log(response) diff --git a/packages/console/function/tsconfig.json b/packages/console/function/tsconfig.json index 3218dd7e3ef..cf99b89bdd6 100644 --- a/packages/console/function/tsconfig.json +++ b/packages/console/function/tsconfig.json @@ -6,6 +6,6 @@ "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "react", - "types": ["@cloudflare/workers-types", "node"] + "types": ["@cloudflare/workers-types", "bun", "node"] } }