From a47ba7168f0548d9cb705d556aed5e6df30900b2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Aug 2026 20:39:23 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A2=20feat:=20Custom=20Request=20Heade?= =?UTF-8?q?rs=20For=20Langfuse=20(#14945)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Custom Request Headers For Langfuse Self-hosted Langfuse behind an authenticating proxy or gateway could not be reached: every outbound Langfuse request hardcoded `Authorization` and nothing else. Adds `langfuse.headers`, mirroring `endpoints.custom` headers, and applies it to all four request surfaces β€” trace/media export (via the agents run config), feedback scores, central project-identity lookup, and admin credential verification. Values resolve through the same pipeline as endpoint headers, so `${ENV_VAR}` interpolation and header-safe encoding come along. `extractEnvVariable` continues to refuse infrastructure secrets, so a config cannot exfiltrate `MONGO_URI` through a header. A header whose variable is unset is dropped with a one-time warning rather than sent as a literal `${...}`, which a gateway would read as a wrong credential instead of a missing one. Headers merge beneath LibreChat's own `Authorization` on the REST surfaces, matching `mergeHeaders`, so a custom header can never displace the Langfuse credential. These are deployment-level and documented as such: trace export batches spans from every user through a single exporter, so unlike endpoint headers they cannot carry per-user placeholders. The central project-id cache key now includes the headers, so the header-less module warm-up cannot record a proxy rejection against the entry the request path later reads. * πŸ”’ fix: Keep Langfuse Headers Out Of Stored Overrides The generic admin config API accepts any field path inside an allowed section, so `langfuse.headers` could be written through it. Unlike `langfuse.secretKey`, headers are a map rather than one scalar path, so the config secret registry cannot encrypt them at rest or mask them on read β€” an admin-written map would sit in Mongo in plaintext and come back in plaintext, widening exposure of what are gateway credentials. Rejects them on both the dotted-patch and object-upsert routes, the same way process-backed MCP servers are held to librechat.yaml. This is what makes "deployment-level" true rather than merely documented. * πŸ› fix: Wire Config Middleware And Header Collisions For Langfuse Two codex review findings. P1 β€” `api/server/routes/admin/langfuse.js` never mounted `configMiddleware`, so `req.config` was undefined in production and credential verification silently ran without the deployment's proxy headers: exactly the deployments this feature targets could not save a connection. The handler unit tests injected `config` into their mock requests, so they stayed green. Mounts the middleware after the access checks (unauthorized callers still short-circuit first) and adds route-level tests that assert the handler actually receives a resolved config β€” the composition root, not the component. P2 β€” spreading custom headers under `Authorization` only replaced an exact-case collision. A configured `authorization` survived alongside the managed `Authorization` and fetch appends rather than replaces, sending both credentials in one combined value. All four request sites now use `mergeHeaders`, which already merges case-insensitively with the override winning; tests cover the lower- and upper-case variants. * πŸ”’ fix: Mask Langfuse Headers On Read And Harden Value Handling Three codex round-2 findings. P1 β€” the write guard blocked storing `langfuse.headers` in Mongo but did nothing for the read path: `GET /api/admin/config/base` serves the resolved AppConfig through `redactConfigSecrets`, which only knows registered scalar secrets, so a yaml-configured gateway credential was returned in full to any admin with Langfuse read access. Adds a secret-map registry that masks values while keeping key names, so an admin can still see which headers a deployment sets. Masking is safe precisely because these are yaml-only β€” a masked read cannot be round-tripped back over the real values. A malformed non-object value at that path is dropped rather than serialized. P2 β€” `mergeHeaders` indexes one spelling per lowercase name, so a config holding both `authorization` and `AUTHORIZATION` had only one displaced; the survivor was then appended by `Headers` into a combined value. Case variants are now collapsed at resolution, before any consumer sees them. P2 β€” `resolveHeaders` encodes only values it substitutes a user field into, and no user is supplied here, so a literal or interpolated character above U+00FF reached `Headers` unencoded and threw. Final values now go through `encodeHeaderValue`; Latin-1 still passes verbatim. * πŸ”’ fix: Keep Langfuse Header Credentials Out Of Logs And Validate Names Three codex round-3 findings, plus a documented boundary for the fourth. P1 β€” `loadCustomConfig` logs the parsed config at startup (`printConfig` defaults true), so a literal gateway credential in `langfuse.headers` was copied into application logs on every boot, undoing the masking the admin read path had just gained. The printed copy now goes through `redactConfigSecretMaps`, reusing the same registry. Scoped to map-valued secrets so scalar-secret log behavior is unchanged; the live config keeps its real values. P2 β€” a nonempty but invalid field name (` X-Token`, `X Proxy Token`) passed the emptiness check and then threw in the `Headers` constructor, which would break export, verification, lookup, and feedback for the whole deployment rather than that one header. Names are trimmed and validated against the RFC 7230 token grammar, and dropped with a warning otherwise. P2 β€” unresolved `${VAR}` detection tested the *resolved* value, so a credential legitimately containing `${...}` was mistaken for a failed substitution and dropped. Detection now inspects the configured text and checks the referenced variables directly, which also drops references to denylisted infrastructure secrets instead of forwarding them verbatim. The fourth (fanout gateway forwards only `Authorization`, so a tenant Langfuse behind its own proxy is not covered) is a real limitation in a separate component. Documented on the schema field and in the example config rather than left implied. * πŸ”’ fix: Scope Langfuse Headers To Configured Origins Three codex round-4 findings. P1 β€” one header map was attached to every destination a run resolves to. Under fanout that means a credential meant for an internal gateway was also sent to the central destination, typically Langfuse Cloud: an unrelated third-party origin. Headers are now attached only when the destination's origin is one the deployment explicitly configured (a self-hosted base URL, the fanout collector, or a tenant destination set by env). The built-in `*.cloud.langfuse.com` defaults are excluded precisely because nobody pointed at them. For trace export this also means attaching after the export branch settles on a `baseUrl` rather than before, since which destination wins depends on the branch. P2 β€” `encodeHeaderValue` only encodes above U+00FF, so a newline, CR, or NUL passed through and threw in `Headers`, breaking every request rather than the one header. Values are trimmed (the common trailing-newline case) then validated against the legal field-value bytes; an embedded CRLF is a request-splitting attempt and is dropped, not stripped. P2 β€” the write guard matched only the exact `headers` property, so `{ langfuse: { "headers.X-Token": "..." } }` and root-level dotted variants slipped through into the Mixed overrides document, where the nested-map redactor never walks them and a later read returns them in plaintext. All dotted spellings are now rejected. * πŸ”’ fix: Bind Langfuse Headers To One Configured Origin Four codex round-5 findings. P1 β€” the round-4 allowlist still authorized every configured origin, so a deployment with both a collector and an explicit central host sent the same credential to both. `langfuse.headers` is one map with no way to say which endpoint it authenticates to, so it is only unambiguous when the deployment configures exactly one Langfuse origin. Iterating on which origins to guess was the wrong axis; headers are now sent only when there is a single configured origin and the destination is it, with a warning when several make the intent unresolvable. That covers the self-hosted case this feature exists for; multi-destination deployments need per-destination headers the schema cannot yet express. P1 β€” `fetch` defaults to following redirects, and Node strips `Authorization` across origins but keeps arbitrary headers, so a redirect off an allowed origin would hand the gateway credential to a host that passed no check. Requests carrying custom headers now refuse redirects; requests without them keep the default, so nothing changes for existing deployments. P2 β€” `extractEnvVariable`'s whole-string branch is anchored and greedy, so `${CLIENT_ID}:${CLIENT_SECRET}` parsed as one variable name and the raw template was sent as the credential. References are expanded here now, so only literal values reach that path. P2 β€” a valid token name is not necessarily usable: `Transfer-Encoding` makes `fetch` throw and a fixed `Content-Length` misdescribes the body of every other request sharing the map. Request-framing names are dropped. * πŸ› fix: Expand Langfuse Header References Exactly Once Codex round 6 (P2). After expanding `${VAR}` references myself I still handed the result to `resolveHeaders`, which runs `extractEnvVariable` over it again β€” so a credential containing `${PATH}`, or any other name that happens to be set, was silently rewritten on export, verification, lookup, and feedback. The round-3 test only used an *unset* embedded name, which the second pass leaves alone, so it could not catch this. Resolution no longer round-trips through `resolveHeaders`. The only part still wanted from it was stripping `{{...}}` user placeholders, which is now applied directly; expansion, encoding, and validation were already local. Adds a test whose embedded variable is set, which fails against the previous pipeline. * πŸ› fix: Process Langfuse Header Templates Before Substitution Codex round 7 (P2), the mirror of round 6. Having stopped re-expanding the resolved credential, the placeholder strip was still running over it: a token containing `{{LIBRECHAT_USER_ID}}` had that span deleted and `abc{{...}}ghi` went out as `abcghi`. Establishes the invariant the last two rounds were circling. Every template operation β€” placeholder strip, unresolved-reference check, expansion β€” now runs on the operator's configured text, and the credential is substituted last and never touched again. Gateway credentials are arbitrary strings, so none of their bytes are syntax. Also moves the unresolved-reference check after the strip, so it no longer reports a variable inside a `{{...}}` span that the strip removes. --- api/server/routes/admin/langfuse.js | 6 +- api/server/routes/admin/langfuse.test.js | 41 +- .../services/Config/loadCustomConfig.js | 9 +- .../services/Config/loadCustomConfig.spec.js | 27 ++ librechat.example.yaml | 29 ++ packages/api/src/admin/config.handler.spec.ts | 65 +++ packages/api/src/admin/config.ts | 42 ++ packages/api/src/admin/index.ts | 2 +- .../api/src/admin/langfuse.handler.spec.ts | 114 +++++ packages/api/src/admin/langfuse.ts | 33 +- packages/api/src/admin/secrets.spec.ts | 32 ++ packages/api/src/admin/secrets.ts | 61 ++- packages/api/src/langfuse/config.spec.ts | 438 ++++++++++++++++++ packages/api/src/langfuse/config.ts | 40 +- packages/api/src/langfuse/destinations.ts | 83 +++- packages/api/src/langfuse/feedback.spec.ts | 151 ++++++ packages/api/src/langfuse/feedback.ts | 11 +- .../api/src/langfuse/tenantDestinations.ts | 73 +++ packages/api/src/langfuse/utils.ts | 218 +++++++++ packages/api/src/utils/env.ts | 2 +- .../specs/config-schemas.spec.ts | 26 ++ packages/data-provider/src/config.ts | 21 + 22 files changed, 1490 insertions(+), 34 deletions(-) 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;