diff --git a/package.json b/package.json index b4557f9dd..9c83f409a 100644 --- a/package.json +++ b/package.json @@ -2955,6 +2955,13 @@ "type": "boolean", "default": false, "description": "Whether the model supports thinking capabilities" + }, + "requestHeaders": { + "type": "object", + "description": "Additional HTTP headers to include with requests to this model. These reserved headers are not allowed and ignored if present: ['api-key', 'authorization', 'content-type', 'openai-intent', 'x-github-api-version', 'x-initiator', 'x-interaction-id', 'x-interaction-type', 'x-onbehalf-extension-id', 'x-request-id', 'x-vscode-user-agent-library-version']", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -3024,6 +3031,13 @@ "type": "boolean", "default": false, "description": "Whether the model supports thinking capabilities" + }, + "requestHeaders": { + "type": "object", + "description": "Additional HTTP headers to include with requests to this model. These reserved headers are not allowed and ignored if present: ['api-key', 'authorization', 'content-type', 'openai-intent', 'x-github-api-version', 'x-initiator', 'x-interaction-id', 'x-interaction-type', 'x-onbehalf-extension-id', 'x-request-id', 'x-vscode-user-agent-library-version']", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -4267,4 +4281,4 @@ "string_decoder": "npm:string_decoder@1.2.0", "node-gyp": "npm:node-gyp@10.3.1" } -} \ No newline at end of file +} diff --git a/src/extension/byok/common/byokProvider.ts b/src/extension/byok/common/byokProvider.ts index 56f532a10..9cf5534b9 100644 --- a/src/extension/byok/common/byokProvider.ts +++ b/src/extension/byok/common/byokProvider.ts @@ -55,6 +55,7 @@ export interface BYOKModelCapabilities { vision: boolean; thinking?: boolean; editTools?: EndpointEditToolName[]; + requestHeaders?: Record; } export interface BYOKModelRegistry { @@ -117,7 +118,7 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod } const modelName = knownModelInfo?.name || modelId; const contextWinow = knownModelInfo ? (knownModelInfo.maxInputTokens + knownModelInfo.maxOutputTokens) : 128000; - return { + const modelInfo: IChatModelInformation = { id: modelId, name: modelName, version: '1.0.0', @@ -141,6 +142,10 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod is_chat_fallback: false, model_picker_enabled: true }; + if (knownModelInfo?.requestHeaders && Object.keys(knownModelInfo.requestHeaders).length > 0) { + modelInfo.requestHeaders = { ...knownModelInfo.requestHeaders }; + } + return modelInfo; } export function byokKnownModelsToAPIInfo(providerName: string, knownModels: BYOKKnownModels | undefined): LanguageModelChatInformation[] { diff --git a/src/extension/byok/node/openAIEndpoint.ts b/src/extension/byok/node/openAIEndpoint.ts index d365048c2..9e7c28f75 100644 --- a/src/extension/byok/node/openAIEndpoint.ts +++ b/src/extension/byok/node/openAIEndpoint.ts @@ -44,6 +44,52 @@ function hydrateBYOKErrorMessages(response: ChatResponse): ChatResponse { } export class OpenAIEndpoint extends ChatEndpoint { + // Reserved headers that cannot be overridden for security and functionality reasons + private static readonly _reservedHeaders: ReadonlySet = new Set([ + // Authentication & Authorization + 'api-key', + 'authorization', + 'cookie', + 'set-cookie', + // Content & Protocol + 'content-type', + 'content-length', + 'transfer-encoding', + 'host', + // Routing & Proxying + 'proxy-authorization', + 'proxy-authenticate', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'forwarded', + // Security & CORS + 'origin', + 'referer', + 'sec-fetch-site', + 'sec-fetch-mode', + 'sec-fetch-dest', + // Application-specific + 'openai-intent', + 'x-github-api-version', + 'x-initiator', + 'x-interaction-id', + 'x-interaction-type', + 'x-onbehalf-extension-id', + 'x-request-id', + 'x-vscode-user-agent-library-version', + 'user-agent', + ]); + + // RFC 7230 compliant header name pattern: token characters only + private static readonly _validHeaderNamePattern = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/; + + // Maximum limits to prevent abuse + private static readonly _maxHeaderNameLength = 256; + private static readonly _maxHeaderValueLength = 8192; + private static readonly _maxCustomHeaderCount = 20; + + private readonly _customHeaders: Record; constructor( protected readonly modelMetadata: IChatModelInformation, protected readonly _apiKey: string, @@ -58,7 +104,7 @@ export class OpenAIEndpoint extends ChatEndpoint { @IInstantiationService protected instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, @IExperimentationService expService: IExperimentationService, - @ILogService logService: ILogService + @ILogService protected logService: ILogService ) { super( modelMetadata, @@ -74,6 +120,87 @@ export class OpenAIEndpoint extends ChatEndpoint { expService, logService ); + this._customHeaders = this._sanitizeCustomHeaders(modelMetadata.requestHeaders); + } + + private _sanitizeCustomHeaders(headers: Readonly> | undefined): Record { + if (!headers) { + return {}; + } + + const entries = Object.entries(headers); + + if (entries.length > OpenAIEndpoint._maxCustomHeaderCount) { + this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has ${entries.length} custom headers, exceeding limit of ${OpenAIEndpoint._maxCustomHeaderCount}. Only first ${OpenAIEndpoint._maxCustomHeaderCount} will be processed.`); + } + + const sanitized: Record = {}; + let processedCount = 0; + + for (const [rawKey, rawValue] of entries) { + if (processedCount >= OpenAIEndpoint._maxCustomHeaderCount) { + break; + } + + const key = rawKey.trim(); + if (!key) { + this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has empty header name, skipping.`); + continue; + } + + if (key.length > OpenAIEndpoint._maxHeaderNameLength) { + this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has header name exceeding ${OpenAIEndpoint._maxHeaderNameLength} characters, skipping.`); + continue; + } + + if (!OpenAIEndpoint._validHeaderNamePattern.test(key)) { + this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid header name format: '${key}', Skipping.`); + continue; + } + + const lowerKey = key.toLowerCase(); + if (OpenAIEndpoint._reservedHeaders.has(lowerKey)) { + this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to override reserved header '${key}', skipping.`); + continue; + } + + const sanitizedValue = this._sanitizeHeaderValue(rawValue); + if (sanitizedValue === undefined) { + this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid value for header '${key}': '${rawValue}', skipping.`); + continue; + } + + sanitized[key] = sanitizedValue; + processedCount++; + } + + return sanitized; + } + + private _sanitizeHeaderValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + + if (trimmed.length > OpenAIEndpoint._maxHeaderValueLength) { + return undefined; + } + + // Disallow control characters including CR, LF, and others (0x00-0x1F, 0x7F) + // This prevents HTTP header injection and response splitting attacks + if (/[\x00-\x1F\x7F]/.test(trimmed)) { + return undefined; + } + + // Additional check for potential Unicode issues + // Reject headers with bidirectional override characters or zero-width characters + if (/[\u200B-\u200D\u202A-\u202E\uFEFF]/.test(trimmed)) { + return undefined; + } + + return trimmed; } override createRequestBody(options: ICreateEndpointBodyOptions): IEndpointBody { @@ -149,6 +276,9 @@ export class OpenAIEndpoint extends ChatEndpoint { } else { headers['Authorization'] = `Bearer ${this._apiKey}`; } + for (const [key, value] of Object.entries(this._customHeaders)) { + headers[key] = value; + } return headers; } diff --git a/src/extension/byok/vscode-node/customOAIModelConfigurator.ts b/src/extension/byok/vscode-node/customOAIModelConfigurator.ts index 95449312a..0d7f5d608 100644 --- a/src/extension/byok/vscode-node/customOAIModelConfigurator.ts +++ b/src/extension/byok/vscode-node/customOAIModelConfigurator.ts @@ -19,6 +19,7 @@ interface ModelConfig { editTools?: EndpointEditToolName[]; requiresAPIKey?: boolean; thinking?: boolean; + requestHeaders?: Record; } interface ModelQuickPickItem extends QuickPickItem { diff --git a/src/extension/byok/vscode-node/customOAIProvider.ts b/src/extension/byok/vscode-node/customOAIProvider.ts index a10f14c72..0993b6782 100644 --- a/src/extension/byok/vscode-node/customOAIProvider.ts +++ b/src/extension/byok/vscode-node/customOAIProvider.ts @@ -40,6 +40,7 @@ export function resolveCustomOAIUrl(modelId: string, url: string): string { interface CustomOAIModelInfo extends LanguageModelChatInformation { url: string; thinking: boolean; + requestHeaders?: Record; } export class CustomOAIBYOKModelProvider implements BYOKModelProvider { @@ -67,8 +68,8 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider { - const modelConfig = this._configurationService.getConfig(this.getConfigKey()) as Record; + private getUserModelConfig(): Record }> { + const modelConfig = this._configurationService.getConfig(this.getConfigKey()) as Record }>; return modelConfig; } @@ -90,6 +91,7 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider('chat.byok.ollamaEndpoint', 'http://localhost:11434'); export const AzureModels = defineSetting>('chat.azureModels', {}); - export const CustomOAIModels = defineSetting>('chat.customOAIModels', {}); + export const CustomOAIModels = defineSetting }>>('chat.customOAIModels', {}); export const AutoFixDiagnostics = defineSetting('chat.agent.autoFix', true); export const NotebookFollowCellExecution = defineSetting('chat.notebook.followCellExecution.enabled', false); export const UseAlternativeNESNotebookFormat = defineExpSetting('chat.notebook.enhancedNextEditSuggestions.enabled', false); diff --git a/src/platform/endpoint/common/endpointProvider.ts b/src/platform/endpoint/common/endpointProvider.ts index 08ca7896f..4d121528b 100644 --- a/src/platform/endpoint/common/endpointProvider.ts +++ b/src/platform/endpoint/common/endpointProvider.ts @@ -91,6 +91,7 @@ export interface IModelAPIResponse { export type IChatModelInformation = IModelAPIResponse & { capabilities: IChatModelCapabilities; urlOrRequestMetadata?: string | RequestMetadata; + requestHeaders?: Readonly>; }; export function isChatModelInformation(model: IModelAPIResponse): model is IChatModelInformation {