CVE-2026-41487
Description
Langfuse is an open source large language model engineering platform. From version 3.68.0 to before version 3.167.0, there is a role-based-access control flaw in the LLM connection update flow. An authenticated, low-privileged user of role “member” in a project could request the update of an existing LLM connection to an attacker-controlled baseUrl, causing Langfuse to reuse the stored provider secret and redirect the test request to an attacker-controlled endpoint. This could expose the plaintext provider LLM API key for that connection. The attack is only possible if a user is already part of a project and has “member” scoped access. This issue has been patched in version 3.167.0.
Affected products
1Patches
27527bb0d84bcfix(web): require secret key for LLM test base URL changes (#13055)
2 files changed · +150 −14
web/src/features/llm-api-key/server/router.ts+24 −12 modified@@ -442,25 +442,37 @@ export const llmApiKeyRouter = createTRPCRouter({ }); } - const decryptedSecretKey = - input.secretKey !== undefined && - input.secretKey !== "" && - input.secretKey !== null - ? input.secretKey - : decrypt(existingKey.secretKey); + const hasNewSecretKey = + typeof input.secretKey === "string" && input.secretKey.length > 0; + const baseURL = input.baseURL ?? existingKey.baseURL; + const isBaseURLChanged = baseURL !== existingKey.baseURL; + + if (isBaseURLChanged && !hasNewSecretKey) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Secret key is required when changing the base URL", + }); + } + + const secretKey = hasNewSecretKey + ? (input.secretKey as string) + : decrypt(existingKey.secretKey); // Merge existing key with provided input, giving priority to input - const secretKey = decryptedSecretKey; const adapter = input.adapter ?? (existingKey.adapter as LLMAdapter); const provider = input.provider ?? existingKey.provider; - const baseURL = input.baseURL ?? existingKey.baseURL; const customModels = input.customModels ?? existingKey.customModels; const config = input.config ?? existingKey.config; + + // Never reuse stored headers across a destination change. const extraHeaders = - input.extraHeaders ?? - (existingKey.extraHeaders - ? decryptAndParseExtraHeaders(existingKey.extraHeaders) - : undefined); + input.extraHeaders !== undefined + ? input.extraHeaders + : isBaseURLChanged + ? undefined + : existingKey.extraHeaders + ? decryptAndParseExtraHeaders(existingKey.extraHeaders) + : undefined; return testLLMConnection({ adapter,
web/src/__tests__/server/llm-api-key.servertest.ts+126 −2 modified@@ -1,12 +1,25 @@ /** @jest-environment node */ +jest.mock("@langfuse/shared/src/server", () => { + const actual = jest.requireActual("@langfuse/shared/src/server"); + return { + ...actual, + fetchLLMCompletion: jest.fn(), + }; +}); + import type { Session } from "next-auth"; import { LLMAdapter } from "@langfuse/shared"; import { prisma } from "@langfuse/shared/src/db"; import { appRouter } from "@/src/server/api/root"; import { createInnerTRPCContext } from "@/src/server/api/trpc"; import { decrypt } from "@langfuse/shared/encryption"; -import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server"; +import { + createOrgProjectAndApiKey, + fetchLLMCompletion, +} from "@langfuse/shared/src/server"; + +const mockFetchLLMCompletion = jest.mocked(fetchLLMCompletion); describe("llmApiKey.all RPC", () => { let projectId: string; @@ -49,6 +62,7 @@ describe("llmApiKey.all RPC", () => { const setup = await createOrgProjectAndApiKey(); projectId = setup.projectId; orgId = setup.orgId; + mockFetchLLMCompletion.mockReset().mockResolvedValue({}); session = { expires: "1", @@ -187,7 +201,7 @@ describe("llmApiKey.all RPC", () => { ).rejects.toThrow("User does not have access to this resource or action"); }); - it("should require llmApiKeys:update access for testing an existing llm api key", async () => { + it("should require llmApiKeys:create access for testing an existing llm api key", async () => { await caller.llmApiKey.create({ projectId, provider: "openai", @@ -216,6 +230,116 @@ describe("llmApiKey.all RPC", () => { ).rejects.toThrow("User does not have access to this resource or action"); }); + it("should block testUpdate when the base URL changes without a new secret key", async () => { + await caller.llmApiKey.create({ + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + secretKey: "sk-original", + baseURL: "https://api.openai.com/v1", + }); + + const existingKey = await prisma.llmApiKeys.findFirstOrThrow({ + where: { + projectId, + provider: "openai", + }, + }); + + const result = await caller.llmApiKey.testUpdate({ + id: existingKey.id, + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + baseURL: "https://attacker.example.com/v1", + }); + + expect(result).toEqual({ + success: false, + error: "Secret key is required when changing the base URL", + }); + expect(mockFetchLLMCompletion).not.toHaveBeenCalled(); + }); + + it("should allow testUpdate without a new secret key when the base URL is unchanged", async () => { + const existingExtraHeaders = { + Authorization: "Bearer stored-token", + "X-Custom-Header": "stored-value", + }; + + await caller.llmApiKey.create({ + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + secretKey: "sk-original", + baseURL: "https://api.openai.com/v1", + extraHeaders: existingExtraHeaders, + }); + + const existingKey = await prisma.llmApiKeys.findFirstOrThrow({ + where: { + projectId, + provider: "openai", + }, + }); + + const result = await caller.llmApiKey.testUpdate({ + id: existingKey.id, + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + baseURL: "https://api.openai.com/v1", + }); + + expect(result).toEqual({ success: true }); + expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1); + const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection; + expect(llmConnection.baseURL).toBe("https://api.openai.com/v1"); + expect(decrypt(llmConnection.secretKey)).toBe("sk-original"); + expect(JSON.parse(decrypt(llmConnection.extraHeaders))).toEqual( + existingExtraHeaders, + ); + }); + + it("should allow testUpdate when the base URL changes and a new secret key is provided", async () => { + const existingExtraHeaders = { + Authorization: "Bearer stored-token", + "X-Custom-Header": "stored-value", + }; + + await caller.llmApiKey.create({ + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + secretKey: "sk-original", + baseURL: "https://api.openai.com/v1", + extraHeaders: existingExtraHeaders, + }); + + const existingKey = await prisma.llmApiKeys.findFirstOrThrow({ + where: { + projectId, + provider: "openai", + }, + }); + + const result = await caller.llmApiKey.testUpdate({ + id: existingKey.id, + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + secretKey: "sk-rotated", + baseURL: "https://new-endpoint.example.com/v1", + }); + + expect(result).toEqual({ success: true }); + expect(mockFetchLLMCompletion).toHaveBeenCalledTimes(1); + const llmConnection = mockFetchLLMCompletion.mock.calls[0][0].llmConnection; + expect(llmConnection.baseURL).toBe("https://new-endpoint.example.com/v1"); + expect(decrypt(llmConnection.secretKey)).toBe("sk-rotated"); + expect(llmConnection.extraHeaders).toBeUndefined(); + }); + it("should create and update an llm api key", async () => { const secret = "test-secret"; const provider = "openai";
e12386f9d436fix(llm-connections): enforce write permissions on LLM connection test endpoints (#13027)
2 files changed · +89 −8
web/src/features/llm-api-key/server/router.ts+13 −7 modified@@ -399,7 +399,13 @@ export const llmApiKeyRouter = createTRPCRouter({ test: protectedProjectProcedureWithoutTracing .input(CreateLlmApiKey) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { + throwIfNoProjectAccess({ + session: ctx.session, + projectId: input.projectId, + scope: "llmApiKeys:create", + }); + return testLLMConnection({ adapter: input.adapter, provider: input.provider, @@ -414,13 +420,13 @@ export const llmApiKeyRouter = createTRPCRouter({ testUpdate: protectedProjectProcedureWithoutTracing .input(UpdateLlmApiKey) .mutation(async ({ input, ctx }) => { - try { - throwIfNoProjectAccess({ - session: ctx.session, - projectId: input.projectId, - scope: "llmApiKeys:read", - }); + throwIfNoProjectAccess({ + session: ctx.session, + projectId: input.projectId, + scope: "llmApiKeys:update", + }); + try { // Get the existing key from the database const existingKey = await ctx.prisma.llmApiKeys.findUnique({ where: {
web/src/__tests__/server/llm-api-key.servertest.ts+76 −1 modified@@ -11,14 +11,46 @@ import { createOrgProjectAndApiKey } from "@langfuse/shared/src/server"; describe("llmApiKey.all RPC", () => { let projectId: string; let orgId: string; + let session: Session; let caller: ReturnType<typeof appRouter.createCaller>; + const createCallerForProjectRole = ( + projectRole: "ADMIN" | "MEMBER" | "VIEWER", + ) => { + const limitedSession: Session = { + ...session, + user: { + ...session.user!, + admin: false, + organizations: [ + { + ...session.user!.organizations[0], + role: "MEMBER", + projects: [ + { + ...session.user!.organizations[0].projects[0], + role: projectRole, + }, + ], + }, + ], + }, + }; + + const limitedCtx = createInnerTRPCContext({ + session: limitedSession, + headers: {}, + }); + + return appRouter.createCaller({ ...limitedCtx, prisma }); + }; + beforeEach(async () => { const setup = await createOrgProjectAndApiKey(); projectId = setup.projectId; orgId = setup.orgId; - const session: Session = { + session = { expires: "1", user: { id: "user-1", @@ -141,6 +173,49 @@ describe("llmApiKey.all RPC", () => { expect(secretKey).toBeUndefined(); }); + it("should require llmApiKeys:create access for testing a new llm api key", async () => { + const memberCaller = createCallerForProjectRole("MEMBER"); + + await expect( + memberCaller.llmApiKey.test({ + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + secretKey: "sk-test", + baseURL: "https://attacker.example.com/v1", + }), + ).rejects.toThrow("User does not have access to this resource or action"); + }); + + it("should require llmApiKeys:update access for testing an existing llm api key", async () => { + await caller.llmApiKey.create({ + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + secretKey: "sk-test", + baseURL: "https://api.openai.com/v1", + }); + + const existingKey = await prisma.llmApiKeys.findFirstOrThrow({ + where: { + projectId, + provider: "openai", + }, + }); + + const memberCaller = createCallerForProjectRole("MEMBER"); + + await expect( + memberCaller.llmApiKey.testUpdate({ + id: existingKey.id, + projectId, + provider: "openai", + adapter: LLMAdapter.OpenAI, + baseURL: "https://attacker.example.com/v1", + }), + ).rejects.toThrow("User does not have access to this resource or action"); + }); + it("should create and update an llm api key", async () => { const secret = "test-secret"; const provider = "openai";
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/langfuse/langfuse/commit/7527bb0d84bc0a3dc24a4b16d22ed2e46e6dddffnvdPatch
- github.com/langfuse/langfuse/commit/e12386f9d4368bbfff24a4ad7fd53641091605ffnvdPatch
- github.com/langfuse/langfuse/pull/13027nvdIssue TrackingPatch
- github.com/langfuse/langfuse/pull/13055nvdIssue TrackingPatch
- github.com/langfuse/langfuse/security/advisories/GHSA-2524-j966-gfghnvdMitigationPatchVendor Advisory
- github.com/langfuse/langfuse/releases/tag/v3.167.0nvdRelease Notes
News mentions
0No linked articles in our index yet.