Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Added an opt-in `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` environment variable that sends the authenticated user's lower-cased email to language model providers in the `X-Sourcebot-User-Email` header. [#1455](https://github.com/sourcebot-dev/sourcebot/pull/1455)

### Fixed
- [EE] Verified signed online license assertions before granting paid feature entitlements. [#1442](https://github.com/sourcebot-dev/sourcebot/pull/1442)
- [EE] Fixed worker startup races that could disable GitHub App authentication and permission syncing until restart after an online license refresh. [#1454](https://github.com/sourcebot-dev/sourcebot/pull/1454)
Expand Down
1 change: 1 addition & 0 deletions docs/docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `ALWAYS_INDEX_FILE_PATTERNS` | - | <p>A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.</p> |
| `SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES` | `10485760` (10 MiB) | <p>Maximum size in bytes of a single image attachment uploaded to Ask Sourcebot. Enforced server-side at upload time.</p> |
| `SOURCEBOT_CHAT_ATTACHMENT_ORPHAN_TTL_HOURS` | `24` | <p>How long in hours an uploaded-but-unsent attachment is retained before being deleted by the orphan sweep. Set to `0` to disable the sweep.</p> |
| `SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED` | `false` | <p>When enabled, Sourcebot sends the authenticated user's lower-cased email address to configured language model providers in the `X-Sourcebot-User-Email` request header. Anonymous requests omit the header.</p> |
| `NODE_USE_ENV_PROXY` | `0` | <p>Enables Node.js to automatically use `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for network requests. Set to `1` to enable or `0` to disable. See [this doc](https://nodejs.org/en/learn/http/enterprise-network-configuration) for more info.</p> |
| `HTTP_PROXY` | - | <p>HTTP proxy URL for routing non-SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.</p> |
| `HTTPS_PROXY` | - | <p>HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.</p> |
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ const options = {

SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100),
SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED: booleanSchema.default('true'),
SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: booleanSchema.default('false'),
/** TTL for the static block. The moving tail marker always uses the 5m default. */
SOURCEBOT_CHAT_PROMPT_CACHE_STATIC_TTL: z.enum(['5m', '1h']).default('5m'),
/**
Expand Down
103 changes: 103 additions & 0 deletions packages/web/src/features/chat/llm.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
env: {
SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED: 'false',
},
getTokenFromConfig: vi.fn(),
getAuthContext: vi.fn(),
isServiceError: vi.fn(),
}));

vi.mock('@sourcebot/shared', () => mocks);
vi.mock('server-only', () => ({}));
vi.mock('@/lib/posthog', () => ({
createPostHogClient: vi.fn(),
tryGetPostHogDistinctId: vi.fn(),
}));
vi.mock('./logger', () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('@/middleware/withAuth', () => ({
getAuthContext: mocks.getAuthContext,
}));
vi.mock('@/lib/utils', () => ({
isServiceError: mocks.isServiceError,
}));

import {
resolveLanguageModelHeaders,
SOURCEBOT_USER_EMAIL_HEADER,
} from './llm.server';

const resolveHeadersForUser = (
email: string,
configuredHeaders?: Parameters<typeof resolveLanguageModelHeaders>[0],
) => {
mocks.getAuthContext.mockResolvedValue({ user: { email } });
return resolveLanguageModelHeaders(configuredHeaders);
};

describe('resolveLanguageModelHeaders', () => {
beforeEach(() => {
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'false';
mocks.getTokenFromConfig.mockReset();
mocks.getAuthContext.mockReset();
mocks.getAuthContext.mockResolvedValue({ user: undefined });
mocks.isServiceError.mockReset();
mocks.isServiceError.mockReturnValue(false);
});

test('does not add the user email header by default', async () => {
await expect(resolveHeadersForUser('User@Example.com')).resolves.toBeUndefined();
expect(mocks.getAuthContext).not.toHaveBeenCalled();
});

test('adds the current user email in lower case when enabled', async () => {
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';

await expect(resolveHeadersForUser('User@Example.COM')).resolves.toEqual({
[SOURCEBOT_USER_EMAIL_HEADER]: 'user@example.com',
});
});

test('omits the user email header for anonymous requests', async () => {
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';

await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined();
});

test('omits the user email header when auth context resolution fails', async () => {
const authError = { statusCode: 401 };
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';
mocks.getAuthContext.mockResolvedValue(authError);
mocks.isServiceError.mockReturnValue(true);

await expect(resolveLanguageModelHeaders(undefined)).resolves.toBeUndefined();
expect(mocks.isServiceError).toHaveBeenCalledWith(authError);
});

test('preserves configured headers when adding the user email header', async () => {
mocks.env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED = 'true';

await expect(resolveHeadersForUser('Authenticated@Example.com', {
'X-Custom-Header': 'custom-value',
})).resolves.toEqual({
'X-Custom-Header': 'custom-value',
[SOURCEBOT_USER_EMAIL_HEADER]: 'authenticated@example.com',
});
});

test('resolves token-backed configured headers', async () => {
const token = { env: 'CUSTOM_HEADER' };
mocks.getTokenFromConfig.mockResolvedValue('resolved-value');

await expect(resolveLanguageModelHeaders({ Authorization: token })).resolves.toEqual({
Authorization: 'resolved-value',
});
expect(mocks.getTokenFromConfig).toHaveBeenCalledWith(token);
});
});
75 changes: 37 additions & 38 deletions packages/web/src/features/chat/llm.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { Token } from "@sourcebot/schemas/v3/shared.type";
import { env, getTokenFromConfig } from '@sourcebot/shared';
import { extractReasoningMiddleware, JSONValue, wrapLanguageModel } from "ai";
import * as Sentry from "@sentry/nextjs";
import { getAuthContext } from '@/middleware/withAuth';
import { isServiceError } from '@/lib/utils';

// @note: This module resolves a configured language model into an AI SDK
// provider object. It is intentionally FSL (open source) provider plumbing —
Expand All @@ -37,6 +39,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
temperature?: number,
}> => {
const { provider, model: modelId } = config;
const headers = await resolveLanguageModelHeaders(config.headers);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth lookup on automated review calls

Medium Severity

Enabling SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED makes every getAISDKLanguageModelAndOptions call run getAuthContext inside resolveLanguageModelHeaders, including webhook-driven review-agent LLM requests that are not user-initiated. Each diff review can repeat org and membership database work, and any incidental credentials on that request can attach that user’s email to automated provider calls.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9ad3797. Configure here.


const { model: _model, providerOptions } = await (async (): Promise<{
model: AISDKLanguageModelV3,
Expand All @@ -56,9 +59,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
sessionToken: config.sessionToken
? await getTokenFromConfig(config.sessionToken)
: env.AWS_SESSION_TOKEN,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
// Fallback to the default Node.js credential provider chain if no credentials are provided.
// See: https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain
credentialProvider: !config.accessKeyId && !config.accessKeySecret && !config.sessionToken
Expand All @@ -77,10 +78,6 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
const authToken = config.authToken
? await getTokenFromConfig(config.authToken)
: env.ANTHROPIC_AUTH_TOKEN;
const headers = config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined;

const anthropic = createAnthropic({
baseURL: config.baseUrl,
apiKey,
Expand Down Expand Up @@ -111,9 +108,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.AZURE_API_KEY,
apiVersion: config.apiVersion,
resourceName: config.resourceName ?? env.AZURE_RESOURCE_NAME,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

const reasoningSummary = config.reasoningSummary ?? 'auto';
Expand All @@ -131,9 +126,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
const deepseek = createDeepSeek({
baseURL: config.baseUrl,
apiKey: config.token ? (await getTokenFromConfig(config.token)) : env.DEEPSEEK_API_KEY,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand All @@ -146,9 +139,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token
? await getTokenFromConfig(config.token)
: env.GOOGLE_GENERATIVE_AI_API_KEY,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand All @@ -173,9 +164,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
keyFilename: await getTokenFromConfig(config.credentials),
}
} : {}),
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand All @@ -202,9 +191,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
keyFilename: await getTokenFromConfig(config.credentials),
}
} : {}),
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand All @@ -217,9 +204,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token
? await getTokenFromConfig(config.token)
: env.MISTRAL_API_KEY,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand All @@ -232,9 +217,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token
? await getTokenFromConfig(config.token)
: env.OPENAI_API_KEY,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

const reasoningSummary = config.reasoningSummary ?? 'auto';
Expand All @@ -255,9 +238,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token
? await getTokenFromConfig(config.token)
: undefined,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
queryParams: config.queryParams
? await extractLanguageModelKeyValuePairs(config.queryParams)
: undefined,
Expand All @@ -282,9 +263,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token
? await getTokenFromConfig(config.token)
: env.OPENROUTER_API_KEY,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand All @@ -297,9 +276,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
apiKey: config.token
? await getTokenFromConfig(config.token)
: env.XAI_API_KEY,
headers: config.headers
? await extractLanguageModelKeyValuePairs(config.headers)
: undefined,
headers,
});

return {
Expand Down Expand Up @@ -332,7 +309,7 @@ export const getAISDKLanguageModelAndOptions = async (config: LanguageModel): Pr
const extractLanguageModelKeyValuePairs = async (
pairs: {
[k: string]: string | Token;
}
} | undefined
): Promise<Record<string, string>> => {
const resolvedPairs: Record<string, string> = {};

Expand All @@ -353,6 +330,28 @@ const extractLanguageModelKeyValuePairs = async (
return resolvedPairs;
};

export const SOURCEBOT_USER_EMAIL_HEADER = 'X-Sourcebot-User-Email';

export const resolveLanguageModelHeaders = async (
configuredHeaders: Record<string, string | Token> | undefined,
): Promise<Record<string, string> | undefined> => {
const headers = await extractLanguageModelKeyValuePairs(configuredHeaders);

const userEmail = await (async () => {
if (env.SOURCEBOT_LLM_USER_EMAIL_HEADER_ENABLED !== 'true') {
return undefined;
}

const authContext = await getAuthContext();
return isServiceError(authContext) ? undefined : authContext.user?.email;
})();
if (userEmail) {
headers[SOURCEBOT_USER_EMAIL_HEADER] = userEmail.toLowerCase();
}
Comment thread
brendan-kellam marked this conversation as resolved.
Comment thread
brendan-kellam marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate email header with mixed casing

Medium Severity

The resolveLanguageModelHeaders function can send duplicate X-Sourcebot-User-Email headers. If a differently-cased user email header is already configured, the function adds the new header without removing the existing one, which might cause LLM providers to use an incorrect or stale email.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9ad3797. Configure here.


return Object.keys(headers).length > 0 ? headers : undefined;
};

type AnthropicThinkingConfig = NonNullable<AnthropicProviderOptions['thinking']>;
const anthropicThinkingConfigCache = new Map<string, AnthropicThinkingConfig | undefined>();

Expand Down
Loading