From 81550a862948df543a1fb523a0002b53ace840a7 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 21 Aug 2026 14:24:39 -0300 Subject: [PATCH] fix(security): require management scope for OAuth import/auto-import routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth import and auto-import routes create or read provider credentials (connection injection, Cursor token disclosure), but guarded only with isAuthenticated() — which, because /api/oauth/ is PUBLIC-classified, accepts any valid client API key. All ten routes now go through requireManagementAuth, so a non-manage key gets 403 (401 with no credential) while a dashboard session or manage-scope key still works. Default requireLogin=true is unaffected for legitimate operators; keyless requireLogin=false stays open by design. Reported by @EQSTLab via GHSA-mg76-rhpx-gvw3 and @koyokr via GHSA-gxv4-955v-v6cm. --- src/app/api/oauth/cliproxy-import/route.ts | 8 +- src/app/api/oauth/codex/import-token/route.ts | 11 +-- src/app/api/oauth/codex/import/route.ts | 10 +-- src/app/api/oauth/cursor/auto-import/route.ts | 10 +-- src/app/api/oauth/cursor/import/route.ts | 8 +- src/app/api/oauth/kiro/auto-import/route.ts | 10 +-- src/app/api/oauth/kiro/import/route.ts | 8 +- .../api/oauth/raycast/auto-import/route.ts | 8 +- src/app/api/oauth/raycast/import/route.ts | 8 +- src/app/api/oauth/trae/import/route.ts | 8 +- tests/unit/oauth-import-manage-scope.test.ts | 75 +++++++++++++++++++ 11 files changed, 118 insertions(+), 46 deletions(-) create mode 100644 tests/unit/oauth-import-manage-scope.test.ts diff --git a/src/app/api/oauth/cliproxy-import/route.ts b/src/app/api/oauth/cliproxy-import/route.ts index 7c60ba9775..ca579983b8 100644 --- a/src/app/api/oauth/cliproxy-import/route.ts +++ b/src/app/api/oauth/cliproxy-import/route.ts @@ -3,7 +3,7 @@ import path from "path"; import { NextResponse } from "next/server"; import { createProviderConnection } from "@/models"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { scanCliProxyAuthDir, @@ -23,9 +23,9 @@ function cliProxyConfigDir(): string { } async function requireImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function GET(request: Request) { diff --git a/src/app/api/oauth/codex/import-token/route.ts b/src/app/api/oauth/codex/import-token/route.ts index 4601b2ae2d..a5b22c4e37 100644 --- a/src/app/api/oauth/codex/import-token/route.ts +++ b/src/app/api/oauth/codex/import-token/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport"; import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport"; import { createProviderConnection } from "@/models"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; /** @@ -93,10 +93,11 @@ async function parseRequestBody( return { ok: true, resolved: resolved.resolved }; } -async function requireAuth(request: Request): Promise { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 }); +async function requireAuth(request: Request): Promise { + // GHSA-mg76: importing a provider connection is a state-mutating admin action. + // Require management scope (or a dashboard session) rather than accepting any + // valid client key, which the PUBLIC /api/oauth/ classification otherwise allows. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function POST(request: Request) { diff --git a/src/app/api/oauth/codex/import/route.ts b/src/app/api/oauth/codex/import/route.ts index a7302a3a6d..6ad9007261 100644 --- a/src/app/api/oauth/codex/import/route.ts +++ b/src/app/api/oauth/codex/import/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport"; import { createProviderConnection } from "@/models"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts"; @@ -82,10 +82,10 @@ const bodySchema = z.object({ }), }); -async function requireAuth(request: Request): Promise { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +async function requireAuth(request: Request): Promise { + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function POST(request: Request) { diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts index c7ba57c419..7a1fcdda3f 100755 --- a/src/app/api/oauth/cursor/auto-import/route.ts +++ b/src/app/api/oauth/cursor/auto-import/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor"; /** @@ -11,11 +11,9 @@ import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor"; * 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4). */ export async function GET(request: Request) { - if (await isAuthRequired(request)) { - if (!(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + // GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action. + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; try { // Try Cursor IDE first (has both accessToken and machineId) diff --git a/src/app/api/oauth/cursor/import/route.ts b/src/app/api/oauth/cursor/import/route.ts index 845946cae9..89b8c26745 100755 --- a/src/app/api/oauth/cursor/import/route.ts +++ b/src/app/api/oauth/cursor/import/route.ts @@ -6,15 +6,15 @@ import { isCloudEnabled } from "@/models"; import { syncToCloud } from "@/lib/cloudSync"; import { cursorImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { resolveProxyForProvider } from "@/models"; async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } /** diff --git a/src/app/api/oauth/kiro/auto-import/route.ts b/src/app/api/oauth/kiro/auto-import/route.ts index 40b927b4c9..ca61b177dd 100755 --- a/src/app/api/oauth/kiro/auto-import/route.ts +++ b/src/app/api/oauth/kiro/auto-import/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { homedir } from "os"; import { join } from "path"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createProviderConnection, getProviderConnections, @@ -31,11 +31,9 @@ import { * 🔒 Auth-guarded: requires JWT cookie or Bearer API key. */ export async function GET(request: Request) { - if (await isAuthRequired(request)) { - if (!(await isAuthenticated(request))) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - } + // GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action. + const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 }); + if (authError) return authError; const { searchParams } = new URL(request.url); const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro"; diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index d4b89183ab..29aa19d07f 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -11,7 +11,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { kiroImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity"; @@ -38,9 +38,9 @@ export function buildKiroImportError(error: unknown): string { } async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } async function upsertImportedKiroConnection( diff --git a/src/app/api/oauth/raycast/auto-import/route.ts b/src/app/api/oauth/raycast/auto-import/route.ts index 17e4cf48c5..4dc3ac8001 100644 --- a/src/app/api/oauth/raycast/auto-import/route.ts +++ b/src/app/api/oauth/raycast/auto-import/route.ts @@ -14,14 +14,14 @@ import { extractLocalRaycastCredentials, isRaycastLocalExtractAvailable, } from "@/lib/oauth/services/raycastLocal"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { resolveProxyForProvider } from "@/models"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function GET(request: Request) { diff --git a/src/app/api/oauth/raycast/import/route.ts b/src/app/api/oauth/raycast/import/route.ts index 0ff02764fa..266dd1aecc 100644 --- a/src/app/api/oauth/raycast/import/route.ts +++ b/src/app/api/oauth/raycast/import/route.ts @@ -11,14 +11,14 @@ import { createProviderConnection } from "@/models"; import { RaycastService } from "@/lib/oauth/services/raycast"; import { raycastImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { resolveProxyForProvider } from "@/models"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function POST(request: Request) { diff --git a/src/app/api/oauth/trae/import/route.ts b/src/app/api/oauth/trae/import/route.ts index 9f3fba5bac..c3faeb8e90 100644 --- a/src/app/api/oauth/trae/import/route.ts +++ b/src/app/api/oauth/trae/import/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { createProviderConnection } from "@/models"; import { traeImportSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; /** * POST /api/oauth/trae/import @@ -22,9 +22,9 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; * region — optional, default "US-East" */ async function requireOAuthImportAuth(request: Request) { - if (!(await isAuthRequired(request))) return null; - if (await isAuthenticated(request)) return null; - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + // GHSA-mg76: importing a provider connection is a state-mutating admin action; + // require management scope (or a dashboard session), not any valid client key. + return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } export async function POST(request: Request) { diff --git a/tests/unit/oauth-import-manage-scope.test.ts b/tests/unit/oauth-import-manage-scope.test.ts new file mode 100644 index 0000000000..070515c02a --- /dev/null +++ b/tests/unit/oauth-import-manage-scope.test.ts @@ -0,0 +1,75 @@ +/** + * GHSA-mg76-rhpx-gvw3 / GHSA-gxv4-955v-v6cm — OAuth import / auto-import routes + * create or read provider credentials. They were guarded only by isAuthenticated(), + * which (because /api/oauth/ is PUBLIC-classified) accepts ANY valid client API key. + * They must now require MANAGEMENT scope. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oauth-import-manage-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "oauth-import-manage-secret"; +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const codexImportToken = await import("../../src/app/api/oauth/codex/import-token/route.ts"); +const cursorAutoImport = await import("../../src/app/api/oauth/cursor/auto-import/route.ts"); + +test.before(async () => { + process.env.JWT_SECRET = "oauth-import-manage-jwt"; + process.env.INITIAL_PASSWORD = "oauth-import-manage-pass"; + await settingsDb.updateSettings({ requireLogin: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + delete process.env.JWT_SECRET; + delete process.env.INITIAL_PASSWORD; +}); + +function post(route: { POST: (r: Request) => Promise }, key?: string) { + return route.POST( + new Request("http://localhost/api/oauth/codex/import-token", { + method: "POST", + headers: { + "content-type": "application/json", + ...(key ? { authorization: `Bearer ${key}` } : {}), + }, + body: JSON.stringify({ accessToken: "x", name: "poc" }), + }) + ); +} + +function get(route: { GET: (r: Request) => Promise }, key?: string) { + return route.GET( + new Request("http://localhost/api/oauth/cursor/auto-import", { + headers: key ? { authorization: `Bearer ${key}` } : {}, + }) + ); +} + +test("codex/import-token: non-manage key → 403, no key → 401, manage key passes the auth gate (GHSA-mg76)", async () => { + const nonManage = await apiKeysDb.createApiKey("client", "machine-client", []); + const manage = await apiKeysDb.createApiKey("admin", "machine-admin", ["manage"]); + + assert.equal((await post(codexImportToken, nonManage.key)).status, 403, "non-manage key rejected"); + assert.equal((await post(codexImportToken)).status, 401, "no credential rejected"); + + const withManage = await post(codexImportToken, manage.key); + assert.notEqual(withManage.status, 401, "manage key must clear the auth gate"); + assert.notEqual(withManage.status, 403, "manage key must clear the auth gate"); +}); + +test("cursor/auto-import: a non-manage key cannot read the host's Cursor token (GHSA-gxv4)", async () => { + const nonManage = await apiKeysDb.createApiKey("client2", "machine-client2", []); + assert.equal((await get(cursorAutoImport, nonManage.key)).status, 403, "non-manage key rejected"); + assert.equal((await get(cursorAutoImport)).status, 401, "no credential rejected"); +});