From 28d320b5e31fb607039f55ba8440b8ef90df2500 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:54:38 -0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20every=20auth=5Ftoken=20consumer=20?= =?UTF-8?q?requires=20the=20authenticated=20claim=20=E2=80=94=20Cursor=20C?= =?UTF-8?q?LI=20tokens=20are=20not=20dashboard=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #13298 --- src/app/api/auth/status/route.ts | 16 ++++---- src/app/api/settings/require-login/route.ts | 15 +++----- src/lib/ws/handshake.ts | 9 +---- src/server/authz/pipeline.ts | 11 +++++- src/server/ws/liveServer.ts | 10 +---- src/shared/utils/apiAuth.ts | 10 +---- .../dashboard-session-token-13298.test.ts | 11 ++++++ ...oard-session-verifier-source-guard.test.ts | 37 +++++++++++++++++++ 8 files changed, 75 insertions(+), 44 deletions(-) create mode 100644 tests/unit/dashboard-session-verifier-source-guard.test.ts diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts index 62cb1a7191..48f04bd552 100644 --- a/src/app/api/auth/status/route.ts +++ b/src/app/api/auth/status/route.ts @@ -1,25 +1,23 @@ import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; import { cookies } from "next/headers"; -import { jwtVerify } from "jose"; - -function getJwtSecret(): Uint8Array | null { - const secret = process.env.JWT_SECRET?.trim(); - return secret ? new TextEncoder().encode(secret) : null; -} +import { + getDashboardJwtSecret, + verifyDashboardSessionToken, +} from "@/shared/utils/dashboardSessionToken"; export async function GET() { try { const cookieStore = await cookies(); const token = cookieStore.get("auth_token")?.value; - const secret = getJwtSecret(); + const secret = getDashboardJwtSecret(); if (!token || !secret) { return NextResponse.json({ authenticated: false }); } - await jwtVerify(token, secret); - return NextResponse.json({ authenticated: true }); + const payload = await verifyDashboardSessionToken(token, secret); + return NextResponse.json({ authenticated: payload !== null }); } catch { return NextResponse.json({ authenticated: false }); } diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 570221409d..a5d988a43b 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; import { cookies } from "next/headers"; -import { jwtVerify } from "jose"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { getSettings, updateSettings } from "@/lib/db/settings"; import { @@ -8,23 +7,19 @@ import { hashManagementPassword, } from "@/lib/auth/managementPassword"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + getDashboardJwtSecret, + verifyDashboardSessionToken, +} from "@/shared/utils/dashboardSessionToken"; import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts"; import { updateRequireLoginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -function getJwtSecret(): Uint8Array | null { - const secret = process.env.JWT_SECRET?.trim(); - return secret ? new TextEncoder().encode(secret) : null; -} - async function checkSessionAuthenticated(): Promise { try { const cookieStore = await cookies(); const token = cookieStore.get("auth_token")?.value; - const secret = getJwtSecret(); - if (!token || !secret) return false; - await jwtVerify(token, secret); - return true; + return (await verifyDashboardSessionToken(token, getDashboardJwtSecret())) !== null; } catch { return false; } diff --git a/src/lib/ws/handshake.ts b/src/lib/ws/handshake.ts index 454fbe9457..a730171b7b 100644 --- a/src/lib/ws/handshake.ts +++ b/src/lib/ws/handshake.ts @@ -1,4 +1,4 @@ -import { jwtVerify } from "jose"; +import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken"; import { getSettings } from "@/lib/db/settings"; import { validateApiKey } from "@/lib/db/apiKeys"; @@ -44,12 +44,7 @@ async function hasValidSessionCookie(request: Request): Promise { const token = getCookieValue(request.headers.get("cookie"), "auth_token"); if (!token) return false; - try { - await jwtVerify(token, new TextEncoder().encode(secretValue)); - return true; - } catch { - return false; - } + return (await verifyDashboardSessionToken(token, new TextEncoder().encode(secretValue))) !== null; } export function extractWsTokenFromUrl(input: string | URL): string | null { diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 9ef7fed2fd..bc1dbb98cc 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -1,8 +1,9 @@ -import { jwtVerify, SignJWT } from "jose"; +import { SignJWT } from "jose"; import { NextResponse, type NextRequest } from "next/server"; import { getCachedSettings } from "../../lib/db/readCache"; import { isDraining } from "../../lib/gracefulShutdown"; import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard"; +import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken"; import { generateRequestId } from "../../shared/utils/requestId"; import { applyCorsHeaders } from "../cors/origins"; import { validateBrowserMutationOrigin } from "../origin/publicOrigin"; @@ -153,7 +154,13 @@ async function refreshDashboardSessionIfNeeded( if (!token) return; try { - const { payload } = await jwtVerify(token, secret); + const payload = await verifyDashboardSessionToken(token, secret); + if (!payload) { + // Not a dashboard session (foreign/expired/claim-less token): drop it so a + // Cursor CLI token can never ride along as the cookie (#13298). + response.cookies.delete("auth_token"); + return; + } const exp = typeof payload.exp === "number" ? payload.exp : null; if (!exp) return; diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index a6cd0f5e5d..d748abee8f 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -18,9 +18,9 @@ */ import { WebSocketServer, WebSocket } from "ws"; -import { jwtVerify } from "jose"; import { createServer, type IncomingMessage, type ServerResponse } from "http"; import { randomUUID } from "crypto"; +import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken"; // ── Types ───────────────────────────────────────────────────────────────── @@ -190,13 +190,7 @@ async function isDashboardCookieAuthenticated( ): Promise { const token = getCookieValueFromHeader(request.headers, "auth_token"); if (!token || !process.env.JWT_SECRET) return false; - try { - const secret = new TextEncoder().encode(process.env.JWT_SECRET); - await jwtVerify(token, secret); - return true; - } catch { - return false; - } + return (await verifyDashboardSessionToken(token)) !== null; } function extractBearerToken(request: import("http").IncomingMessage): string | null { diff --git a/src/shared/utils/apiAuth.ts b/src/shared/utils/apiAuth.ts index d53c72ea9e..3c7afe96eb 100644 --- a/src/shared/utils/apiAuth.ts +++ b/src/shared/utils/apiAuth.ts @@ -7,10 +7,10 @@ * @module shared/utils/apiAuth */ -import { jwtVerify } from "jose"; import { cookies } from "next/headers"; import { getSettings } from "@/lib/db/settings"; import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes"; +import { verifyDashboardSessionToken } from "@/shared/utils/dashboardSessionToken"; import { extractApiKey } from "@/sse/services/auth"; type RequestLike = { @@ -247,13 +247,7 @@ export async function isDashboardSessionAuthenticated( if (!token) return false; - try { - const secret = new TextEncoder().encode(process.env.JWT_SECRET); - await jwtVerify(token, secret); - return true; - } catch { - return false; - } + return (await verifyDashboardSessionToken(token)) !== null; } // ──────────────── Auth Verification ──────────────── diff --git a/tests/unit/dashboard-session-token-13298.test.ts b/tests/unit/dashboard-session-token-13298.test.ts index d6f0cfc31f..e732ee693d 100644 --- a/tests/unit/dashboard-session-token-13298.test.ts +++ b/tests/unit/dashboard-session-token-13298.test.ts @@ -86,3 +86,14 @@ test("an explicit secret argument wins over the env; a null secret means no sess assert.equal(await verifyDashboardSessionToken(tok), null); assert.equal(await verifyDashboardSessionToken(await sign({ authenticated: true }), null), null); }); + +test("isDashboardSessionAuthenticated(): login token → true, Cursor CLI token → false (route-level)", async () => { + const { isDashboardSessionAuthenticated } = await import("../../src/shared/utils/apiAuth.ts"); + const login = await sign({ authenticated: true }); + const cursor = await sign({ name: "k" }, { iss: "omniroute", aud: "cursor-cli" }); + const req = (cookie: string) => + new Request("http://localhost/api/settings", { headers: { cookie: `auth_token=${cookie}` } }); + assert.equal(await isDashboardSessionAuthenticated(req(login)), true); + assert.equal(await isDashboardSessionAuthenticated(req(cursor)), false); + assert.equal(await isDashboardSessionAuthenticated(req("garbage")), false); +}); diff --git a/tests/unit/dashboard-session-verifier-source-guard.test.ts b/tests/unit/dashboard-session-verifier-source-guard.test.ts new file mode 100644 index 0000000000..518a05f30b --- /dev/null +++ b/tests/unit/dashboard-session-verifier-source-guard.test.ts @@ -0,0 +1,37 @@ +/** + * #13298 source guard: every consumer of the `auth_token` cookie must verify it + * through verifyDashboardSessionToken (which requires `authenticated: true`). + * A bare jose `jwtVerify(` in one of these files re-opens the forgeable-session + * hole (Cursor CLI tokens share JWT_SECRET). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, "../.."); +const VERIFIERS = [ + "src/shared/utils/apiAuth.ts", + "src/server/authz/pipeline.ts", + "src/lib/ws/handshake.ts", + "src/server/ws/liveServer.ts", + "src/app/api/settings/require-login/route.ts", + "src/app/api/auth/status/route.ts", +]; + +for (const rel of VERIFIERS) { + test(`${rel} verifies auth_token only through verifyDashboardSessionToken`, () => { + const src = fs.readFileSync(path.join(ROOT, rel), "utf8"); + assert.match(src, /verifyDashboardSessionToken/, "must import/use the shared verifier"); + assert.doesNotMatch(src, /\bjwtVerify\s*\(/, "bare jwtVerify( is the #13298 regression"); + }); +} + +test("the helper itself is the only src file that calls jwtVerify on the dashboard cookie", () => { + const helper = fs.readFileSync( + path.join(ROOT, "src/shared/utils/dashboardSessionToken.ts"), + "utf8" + ); + assert.match(helper, /jwtVerify\(token, secret\)/); + assert.match(helper, /=== true/); +});