VYPR
Medium severity5.4NVD Advisory· Published May 8, 2026· Updated May 13, 2026

CVE-2026-41487

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

1

Patches

2
7527bb0d84bc

fix(web): require secret key for LLM test base URL changes (#13055)

https://github.com/langfuse/langfuseHassieb PakzadApr 9, 2026via nvd-ref
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";
    
e12386f9d436

fix(llm-connections): enforce write permissions on LLM connection test endpoints (#13027)

https://github.com/langfuse/langfuseHassieb PakzadApr 8, 2026via nvd-ref
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

News mentions

0

No linked articles in our index yet.