diff --git a/api/server/routes/admin/langfuse.js b/api/server/routes/admin/langfuse.js index a8ab284dfd..515a5893b7 100644 --- a/api/server/routes/admin/langfuse.js +++ b/api/server/routes/admin/langfuse.js @@ -6,6 +6,7 @@ const { requireCapability, } = require('~/server/middleware/roles/capabilities'); const { invalidateConfigCaches } = require('~/server/services/Config'); +const configMiddleware = require('~/server/middleware/config/app'); const { requireJwtAuth } = require('~/server/middleware'); const db = require('~/models'); @@ -42,7 +43,10 @@ const handlers = createAdminLangfuseHandlers({ invalidateConfigCaches, }); -router.use(requireJwtAuth, requireAdminAccess, requireLangfuseManage); +// `configMiddleware` runs last so unauthorized callers are rejected before the +// config resolves; credential verification reads the deployment's Langfuse +// headers off `req.config`, so without it proxied hosts reject every request. +router.use(requireJwtAuth, requireAdminAccess, requireLangfuseManage, configMiddleware); router.get('/connection', handlers.getConnection); router.get('/connection/session/:conversationId', handlers.getSessionLink); diff --git a/api/server/routes/admin/langfuse.test.js b/api/server/routes/admin/langfuse.test.js index 10b4d26629..52a1338691 100644 --- a/api/server/routes/admin/langfuse.test.js +++ b/api/server/routes/admin/langfuse.test.js @@ -45,6 +45,17 @@ jest.mock('~/server/services/Config', () => ({ invalidateConfigCaches: jest.fn(), })); +const mockConfigMiddleware = jest.fn((req, _res, next) => { + middlewareCalls.push('config'); + req.config = { langfuse: { headers: { 'CF-Access-Client-Id': 'proxy-client' } } }; + next(); +}); + +jest.mock( + '~/server/middleware/config/app', + () => (req, res, next) => mockConfigMiddleware(req, res, next), +); + jest.mock('~/models', () => ({ findConfigByPrincipal: jest.fn(), patchConfigFields: jest.fn(), @@ -73,7 +84,7 @@ describe('admin Langfuse routes', () => { const response = await request(createApp()).get('/api/admin/langfuse/connection').expect(200); expect(response.body).toEqual({ handler: 'get' }); - expect(middlewareCalls).toEqual(['jwt', 'access:admin']); + expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'config']); expect(mockHasConfigCapability).toHaveBeenCalledWith( { id: 'user-1', @@ -100,7 +111,7 @@ describe('admin Langfuse routes', () => { }; expect(response.body).toEqual({ handler: expectedHandlers[handlerName] }); - expect(middlewareCalls).toEqual(['jwt', 'access:admin']); + expect(middlewareCalls).toEqual(['jwt', 'access:admin', 'config']); expect(mockHandlers[handlerName]).toHaveBeenCalledTimes(1); }); @@ -115,4 +126,30 @@ describe('admin Langfuse routes', () => { expect(mockHandlers[handlerName]).not.toHaveBeenCalled(); }); + + /** + * Credential verification reads the deployment's Langfuse headers off + * `req.config`. The handler unit tests inject `config` into their mock + * requests, so only the mounted router proves the middleware supplying it is + * actually wired up — without it a proxied Langfuse host rejects every + * verification while the handler suite stays green. + */ + it.each([ + ['PUT', '/api/admin/langfuse/connection', 'updateConnection'], + ['POST', '/api/admin/langfuse/connection/test', 'testConnection'], + ])('resolves the app config before %s %s reaches its handler', async (method, path, handler) => { + await request(createApp())[method.toLowerCase()](path).send({}).expect(200); + + expect(mockConfigMiddleware).toHaveBeenCalledTimes(1); + const [req] = mockHandlers[handler].mock.calls[0]; + expect(req.config?.langfuse?.headers).toEqual({ 'CF-Access-Client-Id': 'proxy-client' }); + }); + + it('resolves the app config only after the access checks reject', async () => { + canManageLangfuse = false; + + await request(createApp()).post('/api/admin/langfuse/connection/test').send({}).expect(403); + + expect(mockConfigMiddleware).not.toHaveBeenCalled(); + }); }); diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index c719a84665..2629ed1c8f 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -2,7 +2,7 @@ const path = require('path'); const axios = require('axios'); const yaml = require('js-yaml'); const keyBy = require('lodash/keyBy'); -const { loadYaml } = require('@librechat/api'); +const { loadYaml, redactConfigSecretMaps } = require('@librechat/api'); const { Providers } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); const { @@ -156,9 +156,12 @@ https://www.librechat.ai/docs/configuration/stt_tts`); process.exit(1); } else { if (printConfig) { + // Masks map-valued secrets (e.g. `langfuse.headers`) so literal gateway + // credentials are not copied into application logs on every startup. + const loggableConfig = redactConfigSecretMaps(customConfig); logger.info('Custom config file loaded:'); - logger.info(JSON.stringify(customConfig, null, 2)); - logger.debug('Custom config:', customConfig); + logger.info(JSON.stringify(loggableConfig, null, 2)); + logger.debug('Custom config:', loggableConfig); } } diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index ff7ee90629..ca767a5600 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -234,6 +234,33 @@ describe('loadCustomConfig', () => { expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig); }); + it('masks literal Langfuse header credentials in the startup log', async () => { + const mockConfig = { + version: '1.0', + cache: true, + langfuse: { + enabled: true, + publicKey: 'pk-lf-1', + headers: { + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'gateway-credential', + }, + }, + }; + process.env.CONFIG_PATH = 'validConfig.yaml'; + loadYaml.mockReturnValueOnce(mockConfig); + + const result = await loadCustomConfig(); + + const logged = logger.info.mock.calls.map(([value]) => value).join('\n'); + const debugged = JSON.stringify(logger.debug.mock.calls); + expect(logged).not.toContain('gateway-credential'); + expect(debugged).not.toContain('gateway-credential'); + expect(logged).toContain('***'); + // The masking is for logging only — the live config keeps real values. + expect(result.langfuse.headers['CF-Access-Client-Secret']).toBe('gateway-credential'); + }); + describe('parseCustomParams', () => { const mockConfig = { version: '1.0', diff --git a/librechat.example.yaml b/librechat.example.yaml index 493ba92060..0d4da0fa5e 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -11,6 +11,35 @@ cache: true # That flow verifies the credentials and stores the secret key encrypted; do not # place a plaintext langfuse.secretKey in this file. Environment-managed central # credentials and optional fanout routing are documented in .env.example. +# +# Self-hosted Langfuse behind an authenticating proxy or gateway can be given +# custom request headers. They are sent on every outbound Langfuse request — +# trace and media export, feedback scores, and credential verification. +# Values support ${ENV_VAR} interpolation; a header whose variable is unset is +# dropped with a warning rather than sent as a literal placeholder. +# +# These are deployment-level: trace export batches spans from every user through +# a single exporter, so unlike endpoints.custom headers they cannot carry +# per-user placeholders such as {{LIBRECHAT_USER_ID}}. +# +# Values are masked in admin config reads and in the startup config log, but +# prefer ${ENV_VAR} references over literal credentials here regardless. +# +# Scope: these are sent only when the deployment configures exactly ONE Langfuse +# origin (a self-hosted base URL, or a single tenant destination URL), and only +# to that origin. The map has no way to say which endpoint it authenticates to, +# so a deployment configuring several origins — e.g. a fanout collector plus a +# separate central host — gets a warning and no headers, rather than having a +# gateway credential sent somewhere it was not meant for. +# +# Fanout deployments additionally need collector support: the gateway forwards +# only Authorization upstream, so a tenant Langfuse behind its own proxy is not +# covered even when the collector receives these. +# +# langfuse: +# headers: +# CF-Access-Client-Id: "${CF_ACCESS_CLIENT_ID}" +# CF-Access-Client-Secret: "${CF_ACCESS_CLIENT_SECRET}" # File storage configuration # Single strategy for all file types (legacy format, still supported) diff --git a/packages/api/src/admin/config.handler.spec.ts b/packages/api/src/admin/config.handler.spec.ts index 1bc8be13c5..7d44f65a12 100644 --- a/packages/api/src/admin/config.handler.spec.ts +++ b/packages/api/src/admin/config.handler.spec.ts @@ -436,6 +436,50 @@ describe('createAdminConfigHandlers', () => { expect(deps.upsertConfig).not.toHaveBeenCalled(); }); + it('rejects Langfuse header overrides, which cannot be encrypted at rest', async () => { + const { handlers, deps } = createHandlers(); + const req = mockReq({ + params: { principalType: 'user', principalId: 'u1' }, + body: { + overrides: { + langfuse: { enabled: true, headers: { 'X-Proxy-Token': 'leaked' } }, + }, + }, + }); + const res = mockRes(); + + await handlers.upsertConfigOverrides(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: 'Langfuse request headers can only be configured in librechat.yaml', + }); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + + it.each([ + ['nested dotted key', { langfuse: { 'headers.X-Proxy-Token': 'credential' } }], + ['root dotted path', { 'langfuse.headers': { 'X-Proxy-Token': 'credential' } }], + ['root dotted header path', { 'langfuse.headers.X-Proxy-Token': 'credential' }], + ])('rejects Langfuse headers supplied as a %s', async (_label, overrides) => { + const { handlers, deps } = createHandlers(); + const res = mockRes(); + + await handlers.upsertConfigOverrides( + mockReq({ params: { principalType: 'user', principalId: 'u1' }, body: { overrides } }), + res, + ); + + /** `overrides` is a Mixed document written wholesale, so a dotted key + * persists verbatim and the nested-map redactor never walks it — the + * credential would come back in plaintext on the next read. */ + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: 'Langfuse request headers can only be configured in librechat.yaml', + }); + expect(deps.upsertConfig).not.toHaveBeenCalled(); + }); + it('rejects process-backed MCP servers supplied through the runtime config alias', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ @@ -1042,6 +1086,27 @@ describe('createAdminConfigHandlers', () => { expect(deps.patchConfigFields).not.toHaveBeenCalled(); }); + it('rejects Langfuse header field patches, including a single header path', async () => { + const { handlers, deps } = createHandlers(); + + for (const fieldPath of ['langfuse.headers', 'langfuse.headers.X-Proxy-Token']) { + const res = mockRes(); + await handlers.patchConfigField( + mockReq({ + params: { principalType: 'user', principalId: 'u1' }, + body: { entries: [{ fieldPath, value: 'leaked' }] }, + }), + res, + ); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ + error: 'Langfuse request headers can only be configured in librechat.yaml', + }); + } + expect(deps.patchConfigFields).not.toHaveBeenCalled(); + }); + it('rejects process-backed MCP field patches through the runtime config alias', async () => { const { handlers, deps } = createHandlers(); const req = mockReq({ diff --git a/packages/api/src/admin/config.ts b/packages/api/src/admin/config.ts index b6961eacd3..20852cc1a3 100644 --- a/packages/api/src/admin/config.ts +++ b/packages/api/src/admin/config.ts @@ -36,6 +36,41 @@ const BASE_ONLY_OVERRIDE_SECTIONS = new Set(BASE_ONLY_CONFIG_SECTIONS); const BASE_PRINCIPAL_OVERRIDE_SECTIONS = new Set(BASE_PRINCIPAL_CONFIG_SECTIONS); const PROCESS_MCP_CONFIG_ERROR = 'Process-backed MCP servers can only be configured in librechat.yaml'; +const LANGFUSE_HEADERS_CONFIG_ERROR = + 'Langfuse request headers can only be configured in librechat.yaml'; + +/** + * Langfuse export headers carry proxy/gateway credentials, but they are a map + * of values rather than one scalar path, so the config secret registry cannot + * encrypt them at rest or mask them on read. Keeping them out of stored + * overrides is what makes them deployment-level: an admin-written map would sit + * in Mongo in plaintext and come back in plaintext, unlike `langfuse.secretKey`. + */ +function isLangfuseHeadersFieldPath(fieldPath: string): boolean { + return fieldPath === 'langfuse.headers' || fieldPath.startsWith('langfuse.headers.'); +} + +/** + * Whether an overrides payload carries Langfuse headers under any spelling. + * + * `overrides` is a Mixed document written wholesale, so a dotted property name + * survives verbatim: `{ langfuse: { "headers.X-Token": "..." } }` and + * `{ "langfuse.headers": {...} }` both persist a credential that the nested-map + * redactor never walks, and a later read returns it unchanged. + */ +function hasLangfuseHeadersOverride(rawOverrides: Record): boolean { + for (const key of Object.keys(rawOverrides)) { + if (key === 'langfuse.headers' || key.startsWith('langfuse.headers.')) { + return true; + } + } + + const rawLangfuse = rawOverrides.langfuse; + if (rawLangfuse == null || typeof rawLangfuse !== 'object' || Array.isArray(rawLangfuse)) { + return false; + } + return Object.keys(rawLangfuse).some((key) => key === 'headers' || key.startsWith('headers.')); +} export function isValidFieldPath(path: string): boolean { return ( @@ -527,6 +562,10 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { return res.status(400).json({ error: PROCESS_MCP_CONFIG_ERROR }); } + if (hasLangfuseHeadersOverride(rawOverrides)) { + return res.status(400).json({ error: LANGFUSE_HEADERS_CONFIG_ERROR }); + } + if (priority != null && (typeof priority !== 'number' || priority < 0)) { return res.status(400).json({ error: 'priority must be a non-negative number' }); } @@ -731,6 +770,9 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps): { if (isProcessMCPServerFieldPath(entry.fieldPath, entry.value)) { return res.status(400).json({ error: PROCESS_MCP_CONFIG_ERROR }); } + if (isLangfuseHeadersFieldPath(entry.fieldPath)) { + return res.status(400).json({ error: LANGFUSE_HEADERS_CONFIG_ERROR }); + } if (isConfigSecretDescendantPath(entry.fieldPath)) { return res .status(400) diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index ef8ec27ad0..2f52e1cccf 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -6,7 +6,7 @@ export { createAdminRolesHandlers } from './roles'; export { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } from './skills'; export { createAdminUsersHandlers } from './users'; export { createAdminAuditLogHandlers } from './auditLog'; -export { resolveConfigSecret } from './secrets'; +export { resolveConfigSecret, redactConfigSecretMaps } from './secrets'; export type { AdminConfigDeps } from './config'; export type { AdminLangfuseDeps } from './langfuse'; export type { AdminGrantsDeps, GrantPrincipalType } from './grants'; diff --git a/packages/api/src/admin/langfuse.handler.spec.ts b/packages/api/src/admin/langfuse.handler.spec.ts index b316aca328..474029cab1 100644 --- a/packages/api/src/admin/langfuse.handler.spec.ts +++ b/packages/api/src/admin/langfuse.handler.spec.ts @@ -957,5 +957,119 @@ describe('createAdminLangfuseHandlers', () => { expect(res.body).toEqual({ success: false, errorCode: 'missing_secret' }); expect(global.fetch).not.toHaveBeenCalled(); }); + + it('sends the deployment headers on both verification requests', async () => { + /** Single-tenant topology with one configured Langfuse origin — the + * self-hosted-behind-a-proxy case — so the header map is unambiguous. */ + delete process.env.TENANT_ISOLATION_STRICT; + delete process.env.LANGFUSE_FANOUT_ENABLED; + delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL; + process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL = 'https://eu.langfuse.internal'; + global.fetch = jest + .fn() + .mockResolvedValueOnce(projectResponse()) + .mockResolvedValueOnce({ ok: true, status: 207 }) as unknown as typeof fetch; + const { handlers } = createHandlers(); + const res = mockRes(); + + await handlers.testConnection( + mockReq({ + body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' }, + config: { langfuse: { headers: { 'CF-Access-Client-Id': 'proxy-client' } } }, + }), + res, + ); + + expect(res.body).toEqual({ success: true }); + const [, projectsInit] = (global.fetch as unknown as jest.Mock).mock.calls[0]; + expect(projectsInit.headers['CF-Access-Client-Id']).toBe('proxy-client'); + expect(projectsInit.headers.Authorization).toMatch(/^Basic /); + const [, ingestionInit] = (global.fetch as unknown as jest.Mock).mock.calls[1]; + expect(ingestionInit.headers['CF-Access-Client-Id']).toBe('proxy-client'); + expect(ingestionInit.headers.Authorization).toBe('Bearer pk'); + delete process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL; + }); + + it('withholds deployment headers when several Langfuse origins are configured', async () => { + /** The collector from `beforeEach` plus an explicit tenant URL: the map + * does not say which of them it authenticates to, so neither gets it. */ + process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL = 'https://eu.langfuse.internal'; + global.fetch = jest + .fn() + .mockResolvedValueOnce(projectResponse()) + .mockResolvedValueOnce({ ok: true, status: 207 }) as unknown as typeof fetch; + const { handlers } = createHandlers(); + const res = mockRes(); + + await handlers.testConnection( + mockReq({ + body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' }, + config: { langfuse: { headers: { 'CF-Access-Client-Id': 'ambiguous-token' } } }, + }), + res, + ); + + expect(JSON.stringify((global.fetch as unknown as jest.Mock).mock.calls)).not.toContain( + 'ambiguous-token', + ); + delete process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL; + }); + + it('withholds deployment headers when verifying an unconfigured destination', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(projectResponse()) + .mockResolvedValueOnce({ ok: true, status: 207 }) as unknown as typeof fetch; + const { handlers } = createHandlers(); + const res = mockRes(); + + await handlers.testConnection( + mockReq({ + body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' }, + config: { langfuse: { headers: { 'CF-Access-Client-Id': 'internal-gateway' } } }, + }), + res, + ); + + /** `eu` here is the built-in Langfuse Cloud default; an admin selecting it + * must not ship the internal gateway credential to that origin. */ + const calls = (global.fetch as unknown as jest.Mock).mock.calls; + expect(JSON.stringify(calls)).not.toContain('internal-gateway'); + }); + + it.each(['Authorization', 'authorization'])( + 'keeps the Langfuse authorization when a deployment %s header collides', + async (headerName) => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(projectResponse()) + .mockResolvedValueOnce({ ok: true, status: 207 }) as unknown as typeof fetch; + const { handlers } = createHandlers(); + const res = mockRes(); + + delete process.env.TENANT_ISOLATION_STRICT; + delete process.env.LANGFUSE_FANOUT_ENABLED; + delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL; + process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL = 'https://eu.langfuse.internal'; + await handlers.testConnection( + mockReq({ + body: { destination: 'eu', publicKey: 'pk', secretKey: 'sk' }, + config: { langfuse: { headers: { [headerName]: 'Bearer proxy-token' } } }, + }), + res, + ); + delete process.env.LANGFUSE_FANOUT_TENANT_EU_BASE_URL; + + const [, projectsInit] = (global.fetch as unknown as jest.Mock).mock.calls[0]; + const headers = projectsInit.headers as Record; + /** A surviving case variant would be appended by fetch rather than + * replaced, sending both credentials in one combined value. */ + expect( + Object.keys(headers).filter((key) => key.toLowerCase() === 'authorization'), + ).toHaveLength(1); + expect(Object.values(headers)).not.toContain('Bearer proxy-token'); + expect(Object.values(headers).some((value) => value.startsWith('Basic '))).toBe(true); + }, + ); }); }); diff --git a/packages/api/src/admin/langfuse.ts b/packages/api/src/admin/langfuse.ts index f7906d34f4..6a77fd6568 100644 --- a/packages/api/src/admin/langfuse.ts +++ b/packages/api/src/admin/langfuse.ts @@ -2,7 +2,6 @@ import { PrincipalType, PrincipalModel } from 'librechat-data-provider'; import { logger, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas'; import type { TCustomConfig, - LangfuseConfig, TLangfuseConnectionStatus, TUpdateLangfuseConnectionRequest, TLangfuseConnectionTestErrorCode, @@ -19,9 +18,11 @@ import { getLangfuseTenantDestinations, resolveLangfuseTenantDestination, } from '~/langfuse/tenantDestinations'; +import { getLangfuseDestinationId, scopeHeadersToDestination } from '~/langfuse/destinations'; +import { redirectPolicyFor, resolveLangfuseHeaders } from '~/langfuse/utils'; import { decryptConfigSecret, encryptConfigSecretFields } from './secrets'; -import { getLangfuseDestinationId } from '~/langfuse/destinations'; import { isLangfuseConnectionAvailable } from '~/langfuse/policy'; +import { mergeHeaders } from '~/utils/headers'; const DEFAULT_PRIORITY = 10; const ENCRYPTED_PREFIX = 'v3:'; @@ -56,7 +57,10 @@ function getTenantId(req: ServerRequest): string | undefined { return (req.user as { tenantId?: string } | undefined)?.tenantId; } -function readStoredLangfuse(config: IConfig | null): LangfuseConfig | undefined { +/** Reads from the stored override tree, so this is `TCustomConfig`'s + * `DeepPartial` view of the section rather than the standalone + * `LangfuseConfig` — record-valued fields carry optional values here. */ +function readStoredLangfuse(config: IConfig | null): TCustomConfig['langfuse'] { const overrides = config?.overrides as Partial | undefined; return overrides?.langfuse; } @@ -136,13 +140,15 @@ async function verifyLangfuseCredentials( destination: LangfuseTenantDestination, publicKey: string, secretKey: string, + headers?: Record, ): Promise { try { const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64'); const signal = AbortSignal.timeout(LANGFUSE_VERIFICATION_TIMEOUT_MS); const secretResponse = await fetch(`${destination.baseUrl}/api/public/projects`, { - headers: { Authorization: `Basic ${auth}` }, + headers: mergeHeaders(headers, { Authorization: `Basic ${auth}` }), signal, + ...redirectPolicyFor(headers), }); if (!secretResponse.ok) { return { @@ -181,13 +187,14 @@ async function verifyLangfuseCredentials( const publicResponse = await fetch(`${destination.baseUrl}/api/public/ingestion`, { method: 'POST', - headers: { + headers: mergeHeaders(headers, { Authorization: `Bearer ${publicKey}`, 'X-Langfuse-Public-Key': publicKey, 'Content-Type': 'application/json', - }, + }), body: JSON.stringify({ batch: [] }), signal, + ...redirectPolicyFor(headers), }); if (!publicResponse.ok) { return { @@ -372,6 +379,10 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { tenantDestination, publicKey, secretKey, + scopeHeadersToDestination( + resolveLangfuseHeaders(req.config?.langfuse?.headers), + tenantDestination.baseUrl, + ), ); if (!verification.success) { return res @@ -463,7 +474,15 @@ export function createAdminLangfuseHandlers(deps: AdminLangfuseDeps): { return res.status(200).json(failed); } - const result = await verifyLangfuseCredentials(tenantDestination, publicKey, secretKey); + const result = await verifyLangfuseCredentials( + tenantDestination, + publicKey, + secretKey, + scopeHeadersToDestination( + resolveLangfuseHeaders(req.config?.langfuse?.headers), + tenantDestination.baseUrl, + ), + ); const response: TLangfuseConnectionTestResponse = result.success ? { success: true } : { success: false, errorCode: result.errorCode }; diff --git a/packages/api/src/admin/secrets.spec.ts b/packages/api/src/admin/secrets.spec.ts index 39557454a6..d076bc069f 100644 --- a/packages/api/src/admin/secrets.spec.ts +++ b/packages/api/src/admin/secrets.spec.ts @@ -208,6 +208,38 @@ describe('Langfuse config secrets', () => { }); }); + it('masks Langfuse header values while keeping their names', () => { + const redacted = redactConfigSecrets({ + langfuse: { + enabled: true, + publicKey: 'pk-lf-1', + headers: { + 'CF-Access-Client-Id': 'client-id', + 'CF-Access-Client-Secret': 'gateway-credential', + }, + }, + }); + + /** These reach `GET /api/admin/config/base` from librechat.yaml, where no + * scalar secret registration covers them — unmasked, any delegated admin + * with Langfuse read access receives the raw gateway credential. */ + expect(redacted.langfuse).toEqual({ + enabled: true, + publicKey: 'pk-lf-1', + headers: { 'CF-Access-Client-Id': '***', 'CF-Access-Client-Secret': '***' }, + }); + expect(JSON.stringify(redacted)).not.toContain('gateway-credential'); + }); + + it('drops a malformed Langfuse headers value rather than serializing it', () => { + const redacted = redactConfigSecrets({ + langfuse: { publicKey: 'pk-lf-1', headers: 'Bearer raw-credential' }, + }); + + expect(redacted.langfuse).toEqual({ publicKey: 'pk-lf-1' }); + expect(JSON.stringify(redacted)).not.toContain('raw-credential'); + }); + it('strips legacy displaySecretKey companions and migrates them on preserve', () => { const redacted = redactConfigSecrets({ langfuse: { publicKey: 'pk-lf-1', secretKey: 'v3:abc:def', displaySecretKey: 'sk-lf-...old' }, diff --git a/packages/api/src/admin/secrets.ts b/packages/api/src/admin/secrets.ts index 2e80d3b5e4..8064dd6735 100644 --- a/packages/api/src/admin/secrets.ts +++ b/packages/api/src/admin/secrets.ts @@ -781,11 +781,68 @@ export function preserveConfigSecrets(next: T, existing?: unknown, basePath = return result; } +/** + * Config paths holding a *map* of sensitive values rather than one scalar. + * `CONFIG_SECRET_FIELDS` cannot describe these — it keys off a single path and + * a `Preview` companion — so they are masked on read instead. + * + * These are yaml-only (admin writes are rejected), which is what makes masking + * safe: a masked read can never be round-tripped back over the real values. + */ +const CONFIG_SECRET_MAP_FIELDS: readonly string[] = ['langfuse.headers']; +const MASKED_MAP_VALUE = '***'; + +/** + * Replaces every value of a registered secret map with a fixed mask, keeping + * the key names so an admin can still see *which* headers a deployment sets + * without receiving the gateway credentials themselves. + */ +/** + * Masks registered secret maps on a cloned config, for callers outside the + * admin read path that also serialize configuration — notably the startup + * "Custom config file loaded" log, which would otherwise copy every literal + * gateway credential into application logs. + * + * Only handles map-valued secrets; scalar secrets keep whatever handling the + * caller already applies. + */ +export function redactConfigSecretMaps(root: T): T { + const clone = JSON.parse(JSON.stringify(root)) as T; + const rootRecord = getPlainRecord(clone); + if (!rootRecord) { + return clone; + } + redactSecretMapFields(rootRecord); + return clone; +} + +function redactSecretMapFields(rootRecord: Record): void { + for (const path of CONFIG_SECRET_MAP_FIELDS) { + const segments = path.split('.'); + const parent = walkToParent(rootRecord, segments); + const key = segments[segments.length - 1]; + const value = parent?.[key]; + const map = getPlainRecord(value); + if (parent == null) { + continue; + } + if (map == null) { + /** A non-object here is malformed for this path; drop it rather than + * risk serializing a raw string credential. */ + if (value !== undefined) { + delete parent[key]; + } + continue; + } + parent[key] = Object.fromEntries(Object.keys(map).map((name) => [name, MASKED_MAP_VALUE])); + } +} + /** * Deletes registered secret values from `root` in place so admin reads never * return them (encrypted or plaintext). Preview companions and plain * `${ENV_VAR}` references (for fields that allow them) are preserved. - * The caller passes a cloned object. + * Secret *maps* are masked value-by-value. The caller passes a cloned object. */ export function redactConfigSecrets(root: T): T { const rootRecord = getPlainRecord(root); @@ -793,6 +850,8 @@ export function redactConfigSecrets(root: T): T { return root; } + redactSecretMapFields(rootRecord); + for (const key of Object.keys(rootRecord)) { if (key.includes('.') && isConfigSecretRelatedPath(key)) { delete rootRecord[key]; diff --git a/packages/api/src/langfuse/config.spec.ts b/packages/api/src/langfuse/config.spec.ts index 6f10d6ee8b..76c53bb7d0 100644 --- a/packages/api/src/langfuse/config.spec.ts +++ b/packages/api/src/langfuse/config.spec.ts @@ -475,4 +475,442 @@ describe('buildLangfuseConfig', () => { }); }, ); + + it('sends configured headers with env-credential central export', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { buildLangfuseConfig } = await import('./config'); + + expect( + buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { headers: { 'CF-Access-Client-Id': 'proxy-client' } }, + } as unknown as AppConfig, + }), + ).toEqual({ + deterministicTraceId: true, + publicKey: 'pk-env', + secretKey: 'sk-env', + baseUrl: 'https://langfuse.internal', + additionalHeaders: { 'CF-Access-Client-Id': 'proxy-client' }, + }); + }); + + it('sends configured headers with a stored tenant connection', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_FANOUT_TENANT_US_BASE_URL = 'https://us.langfuse.internal'; + const { encryptV3 } = await import('@librechat/data-schemas'); + const { buildLangfuseConfig } = await import('./config'); + + expect( + buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + enabled: true, + publicKey: 'pk-stored', + secretKey: encryptV3('sk-stored'), + destination: 'us', + headers: { 'X-Proxy-Token': 'tenant-token' }, + }, + } as unknown as AppConfig, + }), + ).toEqual({ + deterministicTraceId: true, + publicKey: 'pk-stored', + secretKey: 'sk-stored', + baseUrl: 'https://us.langfuse.internal', + additionalHeaders: { 'X-Proxy-Token': 'tenant-token' }, + }); + delete process.env.LANGFUSE_FANOUT_TENANT_US_BASE_URL; + }); + + it('withholds headers from an origin the deployment never configured', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + const { encryptV3 } = await import('@librechat/data-schemas'); + const { buildLangfuseConfig } = await import('./config'); + + /** The destination here is the built-in Langfuse Cloud default. A proxy + * credential meant for an internal gateway must not be disclosed to a + * third-party origin the operator never pointed at. */ + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + enabled: true, + publicKey: 'pk-stored', + secretKey: encryptV3('sk-stored'), + destination: 'us', + headers: { 'X-Proxy-Token': 'internal-gateway-token' }, + }, + } as unknown as AppConfig, + }); + + expect(built).not.toHaveProperty('additionalHeaders'); + expect(JSON.stringify(built)).not.toContain('internal-gateway-token'); + }); + + it('withholds headers from central cloud export while the collector still receives them', async () => { + process.env.LANGFUSE_FANOUT_ENABLED = 'true'; + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318'; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + const { buildLangfuseConfig } = await import('./config'); + + const appConfig = { + langfuse: { headers: { 'X-Gateway-Key': 'gateway' } }, + } as unknown as AppConfig; + + expect(buildLangfuseConfig({ tenantId: 'tenant-1', appConfig })).toMatchObject({ + baseUrl: 'http://collector:4318', + additionalHeaders: { 'X-Gateway-Key': 'gateway' }, + }); + + /** Without fanout the same config exports straight to Langfuse Cloud, which + * must not receive the collector's credential. */ + delete process.env.LANGFUSE_FANOUT_ENABLED; + delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL; + delete process.env.TENANT_ISOLATION_STRICT; + const direct = buildLangfuseConfig({ runId: 'run-1', appConfig }); + expect(direct).toMatchObject({ baseUrl: 'https://cloud.langfuse.com' }); + expect(direct).not.toHaveProperty('additionalHeaders'); + }); + + it('sends configured headers to the fanout collector', async () => { + process.env.LANGFUSE_FANOUT_ENABLED = 'true'; + process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318'; + const { buildLangfuseConfig } = await import('./config'); + + expect( + buildLangfuseConfig({ + tenantId: 'tenant-1', + appConfig: { + langfuse: { headers: { 'X-Gateway-Key': 'gateway' } }, + } as unknown as AppConfig, + }), + ).toMatchObject({ + baseUrl: 'http://collector:4318', + additionalHeaders: { 'X-Gateway-Key': 'gateway' }, + }); + }); + + it('interpolates env vars in headers and drops the unresolved', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + process.env.LANGFUSE_PROXY_TOKEN = 'resolved-token'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X-Proxy-Token': '${LANGFUSE_PROXY_TOKEN}', + 'X-Missing': '${LANGFUSE_HEADER_NOT_SET}', + 'X-Blank': ' ', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + expect(built.additionalHeaders).toEqual({ 'X-Proxy-Token': 'resolved-token' }); + delete process.env.LANGFUSE_PROXY_TOKEN; + }); + + it('collapses case-variant header names to a single spelling', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + authorization: 'Bearer first', + AUTHORIZATION: 'Bearer second', + 'X-Other': 'kept', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** Two spellings surviving would let one slip past the single-key + * displacement in `mergeHeaders` and be appended by fetch. */ + expect( + Object.keys(built.additionalHeaders ?? {}).filter( + (key) => key.toLowerCase() === 'authorization', + ), + ).toHaveLength(1); + expect(built.additionalHeaders?.['X-Other']).toBe('kept'); + }); + + it('encodes header values that fetch cannot transmit verbatim', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + process.env.LANGFUSE_TEAM_HEADER = 'Marić'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X-Latin1': 'José', + 'X-Extended': 'Marić', + 'X-FromEnv': '${LANGFUSE_TEAM_HEADER}', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** Characters above U+00FF throw in the `Headers` constructor, so they must + * be encoded before they reach any request. Latin-1 passes through. */ + expect(built.additionalHeaders?.['X-Latin1']).toBe('José'); + expect(built.additionalHeaders?.['X-Extended']).toBe('b64:TWFyacSH'); + expect(built.additionalHeaders?.['X-FromEnv']).toBe('b64:TWFyacSH'); + expect(() => new Headers(built.additionalHeaders)).not.toThrow(); + delete process.env.LANGFUSE_TEAM_HEADER; + }); + + it('drops header names that are not valid HTTP tokens', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X Proxy Token': 'spaces', + 'X-Proxy-Token:': 'colon', + ' X-Padded ': 'trimmed-to-valid', + 'X-Valid': 'kept', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** An invalid name throws in the `Headers` constructor, which would take + * down every request rather than just this header. */ + expect(built.additionalHeaders).toEqual({ + 'X-Padded': 'trimmed-to-valid', + 'X-Valid': 'kept', + }); + expect(() => new Headers(built.additionalHeaders)).not.toThrow(); + }); + + it('keeps a resolved credential that itself contains ${...}', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + process.env.LANGFUSE_PROXY_TOKEN = 'abc${def}ghi'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X-Proxy-Token': '${LANGFUSE_PROXY_TOKEN}', + 'X-Missing': '${LANGFUSE_HEADER_NOT_SET}', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** Gateway tokens are arbitrary strings; testing the *resolved* text for + * `${...}` cannot distinguish a failed substitution from a credential + * that merely contains those characters. */ + expect(built.additionalHeaders).toEqual({ 'X-Proxy-Token': 'abc${def}ghi' }); + delete process.env.LANGFUSE_PROXY_TOKEN; + }); + + it('does not strip a user placeholder that a resolved credential contains', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + process.env.LANGFUSE_PROXY_TOKEN = 'abc{{LIBRECHAT_USER_ID}}ghi'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X-Proxy-Token': '${LANGFUSE_PROXY_TOKEN}', + 'X-Templated': 'keep{{LIBRECHAT_USER_ID}}me', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** The placeholder in the *configured* value is stripped; an identical span + * that arrives inside the resolved credential is data, not syntax. */ + expect(built.additionalHeaders).toEqual({ + 'X-Proxy-Token': 'abc{{LIBRECHAT_USER_ID}}ghi', + 'X-Templated': 'keepme', + }); + delete process.env.LANGFUSE_PROXY_TOKEN; + }); + + it('does not re-expand a placeholder that a resolved credential contains', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + process.env.LANGFUSE_EMBEDDED_NAME = 'SHOULD-NOT-APPEAR'; + process.env.LANGFUSE_PROXY_TOKEN = 'abc${LANGFUSE_EMBEDDED_NAME}ghi'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { headers: { 'X-Proxy-Token': '${LANGFUSE_PROXY_TOKEN}' } }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** The embedded name is *set* here, so a second expansion pass would + * rewrite the credential rather than leave it — the earlier test only + * covered an unset name and could not catch that. */ + expect(built.additionalHeaders).toEqual({ + 'X-Proxy-Token': 'abc${LANGFUSE_EMBEDDED_NAME}ghi', + }); + delete process.env.LANGFUSE_EMBEDDED_NAME; + delete process.env.LANGFUSE_PROXY_TOKEN; + }); + + it('drops headers referencing infrastructure secrets', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.JWT_SECRET = 'super-secret-jwt'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { headers: { 'X-Leak': '${JWT_SECRET}' } }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + expect(built).not.toHaveProperty('additionalHeaders'); + expect(JSON.stringify(built)).not.toContain('super-secret-jwt'); + }); + + it('drops header values carrying bytes illegal in an HTTP header', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X-Injected': 'token\r\nX-Evil: injected', + 'X-Nul': 'token', + 'X-Trailing-Newline': 'token\n', + 'X-Clean': 'token', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** A trailing newline is the common `$(cat secret)` case and is trimmed; + * an embedded CRLF is a request-splitting attempt and is dropped. */ + expect(built.additionalHeaders).toEqual({ + 'X-Trailing-Newline': 'token', + 'X-Clean': 'token', + }); + expect(() => new Headers(built.additionalHeaders)).not.toThrow(); + }); + + it('expands a value built from several env references', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + process.env.LANGFUSE_CLIENT_ID = 'client-id'; + process.env.LANGFUSE_CLIENT_SECRET = 'client-secret'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'X-Pair': '${LANGFUSE_CLIENT_ID}:${LANGFUSE_CLIENT_SECRET}', + 'X-Suffixed': 'Bearer ${LANGFUSE_CLIENT_SECRET}', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** `extractEnvVariable`'s anchored branch is greedy, so a value that both + * starts and ends with a reference parses as one variable named + * `LANGFUSE_CLIENT_ID}:${LANGFUSE_CLIENT_SECRET` and is sent raw. */ + expect(built.additionalHeaders).toEqual({ + 'X-Pair': 'client-id:client-secret', + 'X-Suffixed': 'Bearer client-secret', + }); + delete process.env.LANGFUSE_CLIENT_ID; + delete process.env.LANGFUSE_CLIENT_SECRET; + }); + + it('drops headers that control request framing', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { buildLangfuseConfig } = await import('./config'); + + const built = buildLangfuseConfig({ + runId: 'run-1', + appConfig: { + langfuse: { + headers: { + 'Transfer-Encoding': 'chunked', + 'content-length': '42', + Host: 'elsewhere.example', + 'X-Valid': 'kept', + }, + }, + } as unknown as AppConfig, + }) as { additionalHeaders?: Record }; + + /** One of these on the shared map breaks every request the map is attached + * to, rather than being ignored for that header. */ + expect(built.additionalHeaders).toEqual({ 'X-Valid': 'kept' }); + }); + + it('omits headers entirely when none are configured', async () => { + delete process.env.TENANT_ISOLATION_STRICT; + process.env.LANGFUSE_PUBLIC_KEY = 'pk-env'; + process.env.LANGFUSE_SECRET_KEY = 'sk-env'; + const { buildLangfuseConfig } = await import('./config'); + + expect( + buildLangfuseConfig({ + runId: 'run-1', + appConfig: { langfuse: { headers: {} } } as unknown as AppConfig, + }), + ).not.toHaveProperty('additionalHeaders'); + }); }); diff --git a/packages/api/src/langfuse/config.ts b/packages/api/src/langfuse/config.ts index cdea9160a7..b5232ac1fc 100644 --- a/packages/api/src/langfuse/config.ts +++ b/packages/api/src/langfuse/config.ts @@ -9,8 +9,9 @@ import { isLangfuseTracingEnabled, usesLangfuseMultiTenantRouting, } from './policy'; +import { normalizeBoolean, resolveLangfuseHeaders, resolveTenantCredentials } from './utils'; import { resolveLangfuseTenantDestination } from './tenantDestinations'; -import { normalizeBoolean, resolveTenantCredentials } from './utils'; +import { scopeHeadersToDestination } from './destinations'; import { normalizeString } from '~/utils/text'; import { traceIdForMessage } from './trace'; @@ -18,6 +19,7 @@ type LangfuseRunConfig = NonNullable; type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & { librechatTraceAttributes?: Record; mediaUploadEnabled?: boolean; + additionalHeaders?: Record; }; type LangfuseTenantDestination = NonNullable>; type LangfuseExportPlan = @@ -77,6 +79,28 @@ function applyCentralEnvConfig(langfuse: LangfuseRunConfigWithTraceAttributes): } } +/** + * Attaches the deployment's headers only once the export branch has settled on + * a `baseUrl`, and only when that origin is one the operator configured. + * + * A run resolves to a single destination, but which one depends on the branch — + * attaching earlier would send a gateway credential to whatever endpoint the + * config happened to fall through to, including Langfuse Cloud. + */ +function applyCustomHeaders( + langfuse: LangfuseRunConfigWithTraceAttributes, + additionalHeaders?: Record, +): LangfuseRunConfigWithTraceAttributes { + if (langfuse.enabled === false || langfuse.baseUrl == null) { + return langfuse; + } + const scoped = scopeHeadersToDestination(additionalHeaders, langfuse.baseUrl); + if (scoped) { + langfuse.additionalHeaders = scoped; + } + return langfuse; +} + function disableCentralExport(langfuse: LangfuseRunConfigWithTraceAttributes): void { langfuse.librechatTraceAttributes = { ...(langfuse.librechatTraceAttributes ?? {}), @@ -166,6 +190,8 @@ export function buildLangfuseConfig({ return langfuse; } + const additionalHeaders = resolveLangfuseHeaders(config?.headers); + const tenantLangfuseEnabled = normalizeBoolean(config?.enabled) === true; if (!centralTraceExportEnabled) { disableCentralExport(langfuse); @@ -190,7 +216,7 @@ export function buildLangfuseConfig({ } else if (config != null) { langfuse.enabled = false; } - return langfuse; + return applyCustomHeaders(langfuse, additionalHeaders); } const exportPlan = resolveLangfuseExportPlan({ @@ -217,9 +243,11 @@ export function buildLangfuseConfig({ ...(!centralTraceExportEnabled ? [CENTRAL_MEDIA_DISABLED_SEGMENT] : []), ].join('/'), ); - // TODO: Add support in @librechat/agents for Langfuse additionalHeaders and - // route by headers if we need multiple tenant Langfuse exports for one run. - // The destination-scoped URL is the current app-to-gateway routing contract. + // Fanout routing stays destination-scoped by URL. `additionalHeaders` is + // now available (and carries the deployment's proxy headers), but routing + // multiple tenant Langfuse exports for one run by header would need the + // collector to demultiplex them — the URL remains the app-to-gateway + // routing contract until that is required. langfuse.librechatTraceAttributes = { ...(langfuse.librechatTraceAttributes ?? {}), [TENANT_EXPORT_ATTRIBUTE]: 'true', @@ -240,5 +268,5 @@ export function buildLangfuseConfig({ break; } - return langfuse; + return applyCustomHeaders(langfuse, additionalHeaders); } diff --git a/packages/api/src/langfuse/destinations.ts b/packages/api/src/langfuse/destinations.ts index ac5f6a7d36..54ccdf122b 100644 --- a/packages/api/src/langfuse/destinations.ts +++ b/packages/api/src/langfuse/destinations.ts @@ -8,8 +8,19 @@ import { isLangfuseTraceSampled, usesLangfuseMultiTenantRouting, } from './policy'; -import { normalizeBoolean, resolveTenantCredentials, toBasicAuthorization } from './utils'; -import { resolveLangfuseTenantDestination } from './tenantDestinations'; +import { + normalizeBoolean, + redirectPolicyFor, + resolveLangfuseHeaders, + resolveTenantCredentials, + toBasicAuthorization, +} from './utils'; +import { + allowsLangfuseCustomHeaders, + hasAmbiguousLangfuseOrigins, + resolveLangfuseTenantDestination, +} from './tenantDestinations'; +import { mergeHeaders } from '~/utils/headers'; import { normalizeString } from '~/utils/text'; import { traceIdForMessage } from './trace'; @@ -28,6 +39,8 @@ export type LangfuseScoreDestination = { name: 'central' | 'tenant' | 'connection'; baseUrl: string; authorization: string; + /** Deployment proxy/gateway headers, merged beneath `Authorization`. */ + headers?: Record; }; export type LangfuseScoreDestinationOptions = { @@ -40,6 +53,30 @@ export type LangfuseScoreDestinationOptions = { centralTraceExportEnabled?: boolean; }; +let warnedAmbiguousOrigins = false; + +/** Drops the deployment's headers unless this destination is the one configured + * Langfuse origin — a run can resolve to several destinations, and only the + * intended one may receive the gateway credential. */ +export function scopeHeadersToDestination( + headers: Record | undefined, + baseUrl: string, +): Record | undefined { + if (headers == null) { + return undefined; + } + if (allowsLangfuseCustomHeaders(baseUrl)) { + return headers; + } + if (hasAmbiguousLangfuseOrigins() && !warnedAmbiguousOrigins) { + warnedAmbiguousOrigins = true; + logger.warn( + '[langfuse] Not sending langfuse.headers: this deployment configures more than one Langfuse origin, and the headers do not say which one they authenticate to.', + ); + } + return undefined; +} + export function getLangfuseDestinationId(baseUrl: string, projectId: string): string { return createHash('sha256') .update(`${baseUrl.replace(/\/+$/, '')}\n${projectId}`) @@ -60,14 +97,18 @@ async function resolveCentralProjectId( publicKey: string, secretKey: string, waitForLookup: boolean, + headers?: Record, ): Promise { const configuredProjectId = normalizeString(process.env.LANGFUSE_PROJECT_ID); if (configuredProjectId) { return configuredProjectId; } + /** Headers participate in the key so the header-less module warm-up below + * cannot record a proxy rejection against the entry the request path + * (which does send them) later reads. */ const cacheKey = createHash('sha256') - .update(`${baseUrl}\n${publicKey}\n${secretKey}`) + .update(`${baseUrl}\n${publicKey}\n${secretKey}\n${JSON.stringify(headers ?? null)}`) .digest('hex'); const cached = centralProjectIdCache.get(cacheKey) ?? { retryAt: 0 }; centralProjectIdCache.set(cacheKey, cached); @@ -79,8 +120,11 @@ async function resolveCentralProjectId( cached.lookup = (async () => { try { const response = await fetch(`${baseUrl}/api/public/projects`, { - headers: { Authorization: toBasicAuthorization(publicKey, secretKey) }, + headers: mergeHeaders(headers, { + Authorization: toBasicAuthorization(publicKey, secretKey), + }), signal: AbortSignal.timeout(PROJECT_LOOKUP_TIMEOUT_MS), + ...redirectPolicyFor(headers), }); if (!response.ok) { logger.warn( @@ -125,6 +169,7 @@ async function resolveCentralProjectId( async function getCentralScoreDestination( waitForProjectId: boolean, + headers?: Record, ): Promise { // Central feedback scores are sent directly by the app, not through the // collector, so they use LibreChat's normal central Langfuse credentials. @@ -136,16 +181,27 @@ async function getCentralScoreDestination( } const baseUrl = getCentralEnvBaseUrl(); - const projectId = await resolveCentralProjectId(baseUrl, publicKey, secretKey, waitForProjectId); + const scopedHeaders = scopeHeadersToDestination(headers, baseUrl); + const projectId = await resolveCentralProjectId( + baseUrl, + publicKey, + secretKey, + waitForProjectId, + scopedHeaders, + ); return { id: projectId ? getLangfuseDestinationId(baseUrl, projectId) : undefined, name: 'central', baseUrl, authorization: toBasicAuthorization(publicKey, secretKey), + ...(scopedHeaders ? { headers: scopedHeaders } : {}), }; } -function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestination | undefined { +function getTenantScoreDestination( + appConfig?: AppConfig, + headers?: Record, +): LangfuseScoreDestination | undefined { if (!isLangfuseTenantExportEnabled()) { return undefined; } @@ -171,6 +227,7 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat return undefined; } + const scopedHeaders = scopeHeadersToDestination(headers, destination.baseUrl); return { id: config?.projectId ? getLangfuseDestinationId(destination.baseUrl, config.projectId) @@ -178,11 +235,13 @@ function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestinat name: 'tenant', baseUrl: destination.baseUrl, authorization: toBasicAuthorization(tenantCredentials.publicKey, tenantCredentials.secretKey), + ...(scopedHeaders ? { headers: scopedHeaders } : {}), }; } function getConfiguredScoreDestination( appConfig?: AppConfig, + headers?: Record, ): LangfuseScoreDestination | undefined { const config = appConfig?.langfuse; if (normalizeBoolean(config?.enabled) !== true) { @@ -195,6 +254,7 @@ function getConfiguredScoreDestination( return undefined; } + const scopedHeaders = scopeHeadersToDestination(headers, destination.baseUrl); return { id: config?.projectId ? getLangfuseDestinationId(destination.baseUrl, config.projectId) @@ -202,6 +262,7 @@ function getConfiguredScoreDestination( name: 'connection', baseUrl: destination.baseUrl, authorization: toBasicAuthorization(credentials.publicKey, credentials.secretKey), + ...(scopedHeaders ? { headers: scopedHeaders } : {}), }; } @@ -227,6 +288,8 @@ export async function getScoreDestinations( return []; } + const headers = resolveLangfuseHeaders(appConfig?.langfuse?.headers); + if (!usesLangfuseMultiTenantRouting()) { /** Mirrors `resolveLangfuseExportPlan`: without fanout there is no tenant * route to fall back on, so suppressing central export disables the trace @@ -236,19 +299,19 @@ export async function getScoreDestinations( return []; } return hasLangfuseEnvCredentials() - ? [await getCentralScoreDestination(waitForCentralProjectId)].filter( + ? [await getCentralScoreDestination(waitForCentralProjectId, headers)].filter( (destination): destination is LangfuseScoreDestination => Boolean(destination), ) - : [getConfiguredScoreDestination(appConfig)].filter( + : [getConfiguredScoreDestination(appConfig, headers)].filter( (destination): destination is LangfuseScoreDestination => Boolean(destination), ); } const destinations = [ centralTraceExportEnabled - ? await getCentralScoreDestination(waitForCentralProjectId) + ? await getCentralScoreDestination(waitForCentralProjectId, headers) : undefined, - getTenantScoreDestination(appConfig), + getTenantScoreDestination(appConfig, headers), ].filter((destination): destination is LangfuseScoreDestination => Boolean(destination)); const unique = new Map(); for (const destination of destinations) { diff --git a/packages/api/src/langfuse/feedback.spec.ts b/packages/api/src/langfuse/feedback.spec.ts index 9c4be391b1..b4efa3a085 100644 --- a/packages/api/src/langfuse/feedback.spec.ts +++ b/packages/api/src/langfuse/feedback.spec.ts @@ -1284,4 +1284,155 @@ describe('Langfuse feedback scores', () => { }), ); }); + + it('sends configured headers with score creation', async () => { + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + headers: { 'CF-Access-Client-Id': 'proxy-client' }, + }), + }); + + expect(getFetchMock()).toHaveBeenCalledWith( + 'https://langfuse.internal/api/public/scores', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'CF-Access-Client-Id': 'proxy-client', + Authorization: getCentralAuthorization(), + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('refuses redirects on requests carrying custom headers', async () => { + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + headers: { 'X-Proxy-Token': 'proxy-token' }, + }), + }); + + /** Node drops `Authorization` across a cross-origin redirect but keeps + * arbitrary headers, so following one would hand the gateway credential + * to a host that passed no origin check. */ + const [, init] = getFetchMock().mock.calls[0] as [string, RequestInit]; + expect(init.redirect).toBe('error'); + }); + + it('keeps default redirect handling when no custom headers are configured', async () => { + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({}), + }); + + const [, init] = getFetchMock().mock.calls[0] as [string, RequestInit]; + expect(init.redirect).toBeUndefined(); + }); + + it('withholds configured headers from an unconfigured origin', async () => { + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + headers: { 'CF-Access-Client-Id': 'internal-gateway-token' }, + }), + }); + + /** Default central export is Langfuse Cloud, which the operator never + * pointed at — a gateway credential must not be disclosed to it. */ + const [url, init] = getFetchMock().mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://cloud.langfuse.com/api/public/scores'); + expect(JSON.stringify(init.headers)).not.toContain('internal-gateway-token'); + }); + + it('sends configured headers with score deletion', async () => { + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: null, + appConfig: appConfigWithLangfuse({ + headers: { 'CF-Access-Client-Id': 'proxy-client' }, + }), + }); + + expect(getFetchMock()).toHaveBeenCalledWith( + expect.stringContaining('/api/public/scores/'), + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'CF-Access-Client-Id': 'proxy-client', + Authorization: getCentralAuthorization(), + }), + }), + ); + }); + + it.each(['Authorization', 'authorization', 'AUTHORIZATION'])( + 'never lets a configured %s header displace the Langfuse authorization', + async (headerName) => { + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + headers: { [headerName]: 'Bearer proxy-token' }, + }), + }); + + const [, init] = getFetchMock().mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + /** A surviving case variant would be *appended* by fetch, sending a + * combined "Bearer proxy-token, Basic ..." value rather than ours. */ + expect( + Object.keys(headers).filter((key) => key.toLowerCase() === 'authorization'), + ).toHaveLength(1); + expect(Object.values(headers)).toContain(getCentralAuthorization()); + expect(Object.values(headers)).not.toContain('Bearer proxy-token'); + }, + ); + + it('sends configured headers with the central project identity lookup', async () => { + delete process.env.LANGFUSE_PROJECT_ID; + process.env.LANGFUSE_BASE_URL = 'https://langfuse.internal'; + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: [{ id: 'discovered-project' }] }), { status: 200 }), + ); + const { sendFeedbackScore } = await loadFeedback(); + + await sendFeedbackScore({ + traceId: '86d413435f8b0d7f32d4d010ce769e2e', + feedback: { rating: 'thumbsUp' }, + appConfig: appConfigWithLangfuse({ + headers: { 'CF-Access-Client-Id': 'proxy-client' }, + }), + }); + + expect(getFetchMock()).toHaveBeenCalledWith( + 'https://langfuse.internal/api/public/projects', + expect.objectContaining({ + headers: expect.objectContaining({ + 'CF-Access-Client-Id': 'proxy-client', + Authorization: getCentralAuthorization(), + }), + }), + ); + }); }); diff --git a/packages/api/src/langfuse/feedback.ts b/packages/api/src/langfuse/feedback.ts index ca34ac0750..77a8d4db6d 100644 --- a/packages/api/src/langfuse/feedback.ts +++ b/packages/api/src/langfuse/feedback.ts @@ -1,6 +1,8 @@ import { logger } from '@librechat/data-schemas'; import type { AppConfig } from '@librechat/data-schemas'; import { getScoreDestinations, type LangfuseScoreDestination } from './destinations'; +import { mergeHeaders } from '~/utils/headers'; +import { redirectPolicyFor } from './utils'; export type LangfuseFeedback = { rating?: 'thumbsUp' | 'thumbsDown'; @@ -61,7 +63,8 @@ async function deleteScore(destination: LangfuseScoreDestination, scoreId: strin `${destination.baseUrl}/api/public/scores/${encodeURIComponent(scoreId)}`, { method: 'DELETE', - headers: { Authorization: destination.authorization }, + headers: mergeHeaders(destination.headers, { Authorization: destination.authorization }), + ...redirectPolicyFor(destination.headers), }, ); if (!res.ok && res.status !== 404) { @@ -75,8 +78,12 @@ async function createScore( ): Promise { const res = await fetch(`${destination.baseUrl}/api/public/scores`, { method: 'POST', - headers: { Authorization: destination.authorization, 'Content-Type': 'application/json' }, + headers: mergeHeaders(destination.headers, { + Authorization: destination.authorization, + 'Content-Type': 'application/json', + }), body: JSON.stringify(payload), + ...redirectPolicyFor(destination.headers), }); if (!res.ok) { throw new Error(`score create ${res.status}: ${await res.text()}`); diff --git a/packages/api/src/langfuse/tenantDestinations.ts b/packages/api/src/langfuse/tenantDestinations.ts index 6ba3272235..c475efcb9e 100644 --- a/packages/api/src/langfuse/tenantDestinations.ts +++ b/packages/api/src/langfuse/tenantDestinations.ts @@ -91,6 +91,79 @@ export function getLangfuseTenantDestinations(): LangfuseTenantDestination[] { return uniqueDestinations(defaults); } +function originOf(value: string): string | undefined { + try { + return new URL(value).origin; + } catch { + return undefined; + } +} + +/** + * Origins the deployment explicitly pointed Langfuse traffic at — a self-hosted + * base URL, the fanout collector, or a tenant destination whose URL was set by + * env. The built-in `*.cloud.langfuse.com` defaults are deliberately excluded: + * they are third-party origins nobody configured. + */ +function getConfiguredLangfuseOrigins(): Set { + const origins = new Set(); + const add = (value: unknown): void => { + const baseUrl = normalizeBaseUrl(value); + const origin = baseUrl ? originOf(baseUrl) : undefined; + if (origin) { + origins.add(origin); + } + }; + + add(process.env.LANGFUSE_BASE_URL); + add(process.env.LANGFUSE_HOST); + add(process.env.LANGFUSE_BASEURL); + add(process.env.LANGFUSE_FANOUT_COLLECTOR_URL); + + const configuredList = normalizeString(process.env[DESTINATIONS_ENV]); + if (configuredList) { + for (const destination of parseDestinationList(configuredList)) { + add(destination.baseUrl); + } + return origins; + } + + for (const [key] of DEFAULT_TENANT_DESTINATIONS) { + add(process.env[destinationEnvName(key)]); + } + return origins; +} + +/** + * Whether custom Langfuse headers may be sent to `baseUrl`. + * + * `langfuse.headers` is a single map with no way to say *which* endpoint it + * authenticates to, so it is only unambiguous when the deployment configured + * exactly one Langfuse origin. With several — a collector plus a self-hosted + * central, say — any rule for picking recipients is a guess, and guessing wrong + * discloses a gateway credential to the other origin. So the headers are sent + * only when there is one configured origin and this is it. + * + * That covers the self-hosted-behind-a-proxy case this feature exists for. + * Multi-destination deployments need per-destination header configuration, + * which the schema does not yet express. + */ +export function allowsLangfuseCustomHeaders(baseUrl: string): boolean { + const configured = getConfiguredLangfuseOrigins(); + if (configured.size !== 1) { + return false; + } + const origin = originOf(baseUrl); + return origin != null && configured.has(origin); +} + +/** True when headers were configured but the deployment has several possible + * Langfuse origins, so none can be given the map. Callers use this to explain + * the silence rather than leaving an operator debugging a missing header. */ +export function hasAmbiguousLangfuseOrigins(): boolean { + return getConfiguredLangfuseOrigins().size > 1; +} + export function resolveLangfuseTenantDestination( destinationKey: unknown, ): LangfuseTenantDestination | undefined { diff --git a/packages/api/src/langfuse/utils.ts b/packages/api/src/langfuse/utils.ts index aefc093e38..1766fe7b5a 100644 --- a/packages/api/src/langfuse/utils.ts +++ b/packages/api/src/langfuse/utils.ts @@ -1,13 +1,231 @@ +import { logger } from '@librechat/data-schemas'; +import { isSensitiveEnvVar } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; +import { encodeHeaderValue, stripUnresolvedPlaceholders } from '~/utils/env'; import { decryptConfigSecret } from '~/admin/secrets'; import { normalizeString } from '~/utils/text'; type LangfuseAppConfig = NonNullable; +/** RFC 7230 token characters — the only bytes legal in an HTTP field name. */ +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +/** Legal field-value bytes: visible ASCII, space/tab, and obs-text. A newline, + * carriage return, or NUL is both rejected by `Headers` and a request-splitting + * vector, so such a value is dropped rather than sanitized. */ + +const HEADER_VALUE_PATTERN = /^[\t\x20-\x7e\x80-\xff]*$/; + +/** + * Names that break the request rather than travel with it. `fetch` throws on + * `Transfer-Encoding`, and a fixed `Content-Length` misdescribes the body as + * soon as this shared map is reused on a request with a different one. Since + * the map is attached to export, verification, lookup, and feedback alike, one + * such entry disables the whole integration instead of being ignored. + */ +const FORBIDDEN_HEADER_NAMES = new Set([ + 'connection', + 'content-length', + 'expect', + 'host', + 'keep-alive', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +/** Header names already reported, so a per-run resolution cannot spam the log. + * Bounded by the deployment's configured header names. */ +const droppedHeaderWarnings = new Set(); + +function warnDroppedHeader(name: string, reason: string): void { + const key = `${name}\n${reason}`; + if (droppedHeaderWarnings.has(key)) { + return; + } + droppedHeaderWarnings.add(key); + logger.warn(`[langfuse] Dropping header "${name}": ${reason}`); +} + +/** + * Names of `${VAR}` references in a *configured* value that will not expand — + * either unset, or on the infrastructure denylist `extractEnvVariable` refuses + * to substitute. + * + * Deliberately inspects the pre-expansion text. Testing the resolved string for + * `${...}` cannot tell a failed substitution from a credential that merely + * contains those characters, and gateway tokens are arbitrary strings — a + * working `abc${def}ghi` token would be silently dropped. + */ +function unresolvableEnvRefs(configuredValue: string): string[] { + const unresolved: string[] = []; + for (const match of configuredValue.matchAll(/\$\{([^}]+)\}/g)) { + const name = match[1].trim(); + if (isSensitiveEnvVar(name) || !normalizeString(process.env[name])) { + unresolved.push(name); + } + } + return unresolved; +} + +/** + * Substitutes every `${VAR}` reference, which callers must have already checked + * with `unresolvableEnvRefs`. + * + * Done here rather than left to `extractEnvVariable` because its whole-string + * branch is anchored (`^\${(.+)}$`) and greedy, so a value composed of two + * references — `${CLIENT_ID}:${CLIENT_SECRET}` — parses as one variable named + * `CLIENT_ID}:${CLIENT_SECRET`, resolves to nothing, and the raw template is + * sent as the credential. + */ +function expandEnvRefs(configuredValue: string): string { + return configuredValue.replace( + /\$\{([^}]+)\}/g, + (fullMatch, rawName: string) => process.env[rawName.trim()] ?? fullMatch, + ); +} + +/** Names already reported as duplicate spellings, keyed by lowercase name. */ +const duplicateHeaderWarnings = new Set(); + +/** + * Collapses case-variant spellings of the same header name, keeping the last. + * + * HTTP header names are case-insensitive, but a config map can legally hold + * `authorization` and `AUTHORIZATION` as distinct keys. Downstream merging + * indexes one spelling per name and so can only displace one of them; the + * survivor would then be *appended* by `Headers` into a combined value, + * breaking the very credential it was meant to set. + */ +function collapseHeaderNameVariants(headers: Record): Record { + const byLowerName = new Map(); + for (const name of Object.keys(headers)) { + const lower = name.toLowerCase(); + const existing = byLowerName.get(lower); + if (existing != null && !duplicateHeaderWarnings.has(lower)) { + duplicateHeaderWarnings.add(lower); + logger.warn( + `[langfuse] Header "${name}" duplicates "${existing}" (names are case-insensitive); using "${name}".`, + ); + } + byLowerName.set(lower, name); + } + + if (byLowerName.size === Object.keys(headers).length) { + return headers; + } + return Object.fromEntries([...byLowerName.values()].map((name) => [name, headers[name]])); +} + export function toBasicAuthorization(publicKey: string, secretKey: string): string { return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`; } +/** + * Refuses redirects on requests carrying custom headers. + * + * Node strips `Authorization` when a redirect crosses origins but keeps + * arbitrary headers, so following one would hand the gateway credential to a + * host that passed no origin check. Requests without custom headers keep the + * default follow behavior, so this changes nothing for existing deployments. + */ +export function redirectPolicyFor(headers?: Record): { + redirect?: RequestRedirect; +} { + return headers != null && Object.keys(headers).length > 0 ? { redirect: 'error' } : {}; +} + +/** + * Resolves the deployment's custom Langfuse headers: `${ENV_VAR}` interpolation + * and header-safe encoding, matching what endpoint headers get. + * + * No user or request body is involved — one exporter serves every user's spans, + * so per-user placeholders have no meaning here and are stripped rather than + * forwarded as template syntax. Anything that cannot be sent safely (unset + * reference, illegal name, framing header, unencodable value) is dropped with a + * warning rather than sent malformed: a proxy rejecting a broken credential is + * far harder to diagnose than a missing one. + * + * @returns the resolved headers, or `undefined` when none survive. + */ +export function resolveLangfuseHeaders( + headers?: Record, +): Record | undefined { + if (headers == null) { + return undefined; + } + + /** `TCustomConfig` is a `DeepPartial`, so record values arrive as + * `string | undefined` regardless of what the schema declares. */ + const declared: Record = {}; + for (const [rawName, value] of Object.entries(headers)) { + if (typeof value !== 'string') { + continue; + } + const name = rawName.trim(); + if (name === '') { + continue; + } + /** An illegal field name (` X-Token`, `X Proxy Token`) is rejected by the + * `Headers` constructor, which would throw on every export, verification, + * lookup, and feedback request instead of failing this one header. */ + if (!HEADER_NAME_PATTERN.test(name)) { + warnDroppedHeader(rawName, 'it is not a valid HTTP header name.'); + continue; + } + if (FORBIDDEN_HEADER_NAMES.has(name.toLowerCase())) { + warnDroppedHeader(name, 'it controls request framing and cannot be set on a fetch request.'); + continue; + } + declared[name] = value; + } + if (Object.keys(declared).length === 0) { + return undefined; + } + + const collapsed = collapseHeaderNameVariants(declared); + const entries: Array<[string, string]> = []; + for (const [name, configured] of Object.entries(collapsed)) { + /** + * Every template operation runs on the operator's configured text, and the + * credential is substituted last and never touched again. + * + * Order matters in both directions: expanding first would let a strip or a + * second expansion reinterpret characters that came out of the secret, so a + * token containing `{{LIBRECHAT_USER_ID}}` would have that span deleted and + * one containing `${PATH}` would be rewritten. Gateway credentials are + * arbitrary strings; none of their bytes are template syntax. + */ + const template = stripUnresolvedPlaceholders(configured); + const unresolved = unresolvableEnvRefs(template); + if (unresolved.length > 0) { + warnDroppedHeader(name, `${unresolved.join(', ')} is not set in the environment.`); + continue; + } + const value = expandEnvRefs(template); + + /** Trimmed because a credential read from a file or `$(...)` commonly + * carries a trailing newline, which would otherwise fail validation. */ + const trimmed = value.trim(); + if (trimmed === '') { + continue; + } + /** Nothing else encodes these values, so a literal or interpolated + * character above U+00FF would reach `Headers` unencoded and throw, + * taking down export, verification, and feedback alike. */ + const encoded = encodeHeaderValue(trimmed); + /** `encodeHeaderValue` only encodes above U+00FF, so control bytes below it + * still reach `Headers` and throw — an embedded CR/LF would also be a + * request-splitting attempt, so this drops rather than strips. */ + if (!HEADER_VALUE_PATTERN.test(encoded)) { + warnDroppedHeader(name, 'its value contains characters not allowed in an HTTP header.'); + continue; + } + entries.push([name, encoded]); + } + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + export function resolveTenantCredentials( config?: LangfuseAppConfig, ): { publicKey: string; secretKey: string } | undefined { diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 6c18825e72..1c39d0b9dd 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -170,7 +170,7 @@ const RESOLVABLE_PLACEHOLDER_PATTERN = new RegExp( * users under that one string). Only for final resolution passes — staged * flows that resolve again later with more context must not strip. */ -function stripUnresolvedPlaceholders(value: string): string { +export function stripUnresolvedPlaceholders(value: string): string { return value.replace(RESOLVABLE_PLACEHOLDER_PATTERN, ''); } diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index d81b2ea491..4d3d3161cf 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -1349,4 +1349,30 @@ describe('configSchema langfuse', () => { expect(result.success).toBe(true); }); + + it('accepts custom Langfuse request headers', () => { + const result = configSchema.safeParse({ + version: '1.3.7', + langfuse: { + enabled: true, + headers: { + 'CF-Access-Client-Id': 'proxy-client', + 'X-Proxy-Token': '${LANGFUSE_PROXY_TOKEN}', + }, + }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects non-string Langfuse header values', () => { + const result = configSchema.safeParse({ + version: '1.3.7', + langfuse: { + headers: { 'X-Proxy-Token': 42 }, + }, + }); + + expect(result.success).toBe(false); + }); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5c2a785e8b..a1bcb6489a 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -2040,6 +2040,27 @@ export const langfuseConfigSchema = z.object({ secretKeyPreview: z.string().optional(), /** Routing key for one of the deployment-configured tenant Langfuse destinations. */ destination: z.string().optional(), + /** + * Custom request headers sent on every outbound Langfuse request — trace and + * media export, feedback scores, and credential verification — for + * self-hosted instances behind an authenticating proxy or gateway. Values + * support `${ENV_VAR}` interpolation. + * + * Deployment-level only. Trace export batches spans from every user through + * one exporter, so unlike endpoint headers these cannot carry per-user + * placeholders. Headers referencing an unset variable, naming an + * infrastructure secret, or carrying an invalid HTTP field name are dropped + * with a warning rather than sent. + * + * Sent only when the deployment configures exactly one Langfuse origin, and + * only to that origin. The map cannot say which endpoint it authenticates + * to, so with several configured origins any choice of recipient would risk + * disclosing a gateway credential to the others; a warning is logged instead. + * Multi-destination deployments need per-destination headers, which this + * schema does not yet express — and note the fanout collector forwards only + * `Authorization` upstream regardless. + */ + headers: z.record(z.string()).optional(), }); export type LangfuseConfig = z.infer;