mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🪢 feat: Custom Request Headers For Langfuse (#14945)
* ✨ 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.
This commit is contained in:
parent
7d62be2ad3
commit
a47ba7168f
22 changed files with 1490 additions and 34 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -36,6 +36,41 @@ const BASE_ONLY_OVERRIDE_SECTIONS = new Set<string>(BASE_ONLY_CONFIG_SECTIONS);
|
|||
const BASE_PRINCIPAL_OVERRIDE_SECTIONS = new Set<string>(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<string, unknown>): 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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
/** 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TCustomConfig> | undefined;
|
||||
return overrides?.langfuse;
|
||||
}
|
||||
|
|
@ -136,13 +140,15 @@ async function verifyLangfuseCredentials(
|
|||
destination: LangfuseTenantDestination,
|
||||
publicKey: string,
|
||||
secretKey: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<LangfuseVerificationResult> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -781,11 +781,68 @@ export function preserveConfigSecrets<T>(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 `<path>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<T>(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<string, unknown>): 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<T>(root: T): T {
|
||||
const rootRecord = getPlainRecord(root);
|
||||
|
|
@ -793,6 +850,8 @@ export function redactConfigSecrets<T>(root: T): T {
|
|||
return root;
|
||||
}
|
||||
|
||||
redactSecretMapFields(rootRecord);
|
||||
|
||||
for (const key of Object.keys(rootRecord)) {
|
||||
if (key.includes('.') && isConfigSecretRelatedPath(key)) {
|
||||
delete rootRecord[key];
|
||||
|
|
|
|||
|
|
@ -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<string, string> };
|
||||
|
||||
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<string, string> };
|
||||
|
||||
/** 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<string, string> };
|
||||
|
||||
/** 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<string, string> };
|
||||
|
||||
/** 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<string, string> };
|
||||
|
||||
/** 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<string, string> };
|
||||
|
||||
/** 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<string, string> };
|
||||
|
||||
/** 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<string, string> };
|
||||
|
||||
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': 'tok | ||||