From 5d1b2b7459538b1878c1a800b1b46253c211e4b8 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Fri, 13 Jun 2025 18:49:13 +0300 Subject: [PATCH] [bug] Use providers as allow list in proxyRequest; For bug 75169 --- DocService/sources/ai/aiEngineWrapper.js | 1 + DocService/sources/ai/aiProxyHandler.js | 127 ++++++++++++++++++----- 2 files changed, 100 insertions(+), 28 deletions(-) diff --git a/DocService/sources/ai/aiEngineWrapper.js b/DocService/sources/ai/aiEngineWrapper.js index c09341b3..3a443b5b 100644 --- a/DocService/sources/ai/aiEngineWrapper.js +++ b/DocService/sources/ai/aiEngineWrapper.js @@ -61,6 +61,7 @@ const sandbox = { Providers: {}, InternalProviders: [], _getHeaders: function() {return {};}, + _getEndpointUrl: function() {return "";}, serializeProviders: function() {return [];}, ActionsGetSorted: function() {return [];}, getModels: function() {return [];}, diff --git a/DocService/sources/ai/aiProxyHandler.js b/DocService/sources/ai/aiProxyHandler.js index 84cde9aa..001ae83e 100644 --- a/DocService/sources/ai/aiProxyHandler.js +++ b/DocService/sources/ai/aiProxyHandler.js @@ -33,6 +33,7 @@ 'use strict'; const { pipeline } = require('stream/promises'); +const { URL } = require('url'); const config = require('config'); const utils = require('./../../../Common/sources/utils'); const operationContext = require('./../../../Common/sources/operationContext'); @@ -53,7 +54,7 @@ const AI = aiEngine.AI; * * @param {object} req - Express request object * @param {object} res - Express response object - * @param {object} ctx - Operation context for logging + * @param {operationContext.Context} ctx - Operation context for logging * @param {boolean} handleOptions - Whether to handle OPTIONS requests (default: true) * @returns {boolean} - True if this was an OPTIONS request that was handled */ @@ -98,6 +99,45 @@ function handleCorsHeaders(req, res, ctx, handleOptions = true) { return false; // Not an OPTIONS request or origin not allowed } +/** + * Appends API key to the request URI if the provider passes it as a query parameter. + * + * @param {operationContext.Context} ctx - The operation context for logging. + * @param {object} provider - The AI provider configuration. + * @param {string} uri - The original request URI. + * @returns {string} The updated URI with API key as a query parameter, if applicable. + */ +function appendApiKeyToQuery(ctx, provider, uri) { + const urlWithKey = AI._getEndpointUrl(provider, AI.Endpoints.Types.v1.Models); + + // To check if the key is part of the query, we get the URL without the key. + const originalKey = provider.key; + provider.key = undefined; + const urlWithoutKey = AI._getEndpointUrl(provider, AI.Endpoints.Types.v1.Models); + provider.key = originalKey; // Restore the key on the provider object. + + if (urlWithKey !== urlWithoutKey) { + try { + const parsedUrlWithKey = new URL(urlWithKey); + if (parsedUrlWithKey.search) { + const parsedUri = new URL(uri); + for (const [key, value] of parsedUrlWithKey.searchParams) { + if (originalKey === value) { + parsedUri.searchParams.set(key, value); + break; + } + } + ctx.logger.debug(`appendApiKeyToQuery: Appended API key to URI for provider ${provider.name}`); + return parsedUri.toString(); + } + } catch (error) { + ctx.logger.error(`appendApiKeyToQuery: Failed to parse provider URL for ${provider.name}: ${urlWithKey}`, error); + } + } + + return uri; +} + /** * Makes an HTTP request to an AI API endpoint using the provided request and response objects * @@ -125,42 +165,74 @@ async function proxyRequest(req, res) { if (tenTokenEnableBrowser) { let checkJwtRes = await docsCoServer.checkJwtHeader(ctx, req, 'Authorization', 'Bearer ', commonDefines.c_oAscSecretType.Session); if (!checkJwtRes || checkJwtRes.err) { - ctx.logger.error('checkJwtHeader error: %s', checkJwtRes?.err); - res.sendStatus(403); + ctx.logger.error('proxyRequest: checkJwtHeader error: %s', checkJwtRes?.err); + res.status(403).json({ + "error": { + "message": "proxyRequest: checkJwtHeader error", + "code": "403" + } + }); return; } } + if (!tenAiApi?.providers) { + ctx.logger.error('proxyRequest: No providers configured'); + res.status(403).json({ + "error": { + "message": "proxyRequest: No providers configured", + "code": "403" + } + }); + return; + } + let body = JSON.parse(req.body); + let uri = body.target; + + let providerHeaders; + let providerMatched = false; + // Determine which API key to use based on the target URL + if (uri) { + for (const providerName in tenAiApi.providers) { + const tenProvider = tenAiApi.providers[providerName]; + if (uri.startsWith(tenProvider.url) && AI.Providers[tenProvider.name]) { + providerMatched = true; + const provider = AI.Providers[tenProvider.name]; + provider.key = tenProvider.key; + provider.url = tenProvider.url; + providerHeaders = AI._getHeaders(provider); + + uri = appendApiKeyToQuery(ctx, provider, uri); + break; + } + } + } + // If body.target was provided but no provider was matched, return 403 + if (!providerHeaders) { + ctx.logger.warn(`proxyRequest: target '${uri}' does not match any configured AI provider. Denying access.`); + res.status(403).json({ + "error": { + "message": "proxyRequest: target does not match any configured AI provider", + "code": "403" + } + }); + return; + } + + + // Merge key in headers + const headers = { ...body.headers, ...providerHeaders }; // Configure timeout options for the request const timeoutOptions = { connectionAndInactivity: tenAiApiTimeout, wholeCycle: tenAiApiTimeout }; - - let providerHeaders; - // Determine which API key to use based on the target URL - if (body.target) { - // Find the provider that matches the target URL - for (let providerName in AI.Providers) {//todo try for of - if (body.target.includes(AI.Providers[providerName].url)) { - if (tenAiApi?.providers?.[providerName]) { - AI.Providers[providerName].key = tenAiApi.providers[providerName].key; - AI.Providers[providerName].url = tenAiApi.providers[providerName].url; - } - providerHeaders = AI._getHeaders(AI.Providers[providerName]); - break; - } - } - } - // Merge key in headers - const headers = { ...body.headers, ...providerHeaders }; - // Create request parameters object const requestParams = { method: body.method, - uri: body.target, + uri: uri, headers, body: body.data, timeout: timeoutOptions, @@ -168,7 +240,6 @@ async function proxyRequest(req, res) { filterPrivate: false }; - // Log the sanitized request parameters ctx.logger.debug(`Proxying request: %j`, requestParams); @@ -191,7 +262,7 @@ async function proxyRequest(req, res) { await pipeline(result.stream, res); } catch (error) { - ctx.logger.error(`AI API request error: %s`, error); + ctx.logger.error(`proxyRequest: AI API request error: %s`, error); if (error.response){ // Set the response headers to match the target response res.set(error.response.headers); @@ -201,7 +272,7 @@ async function proxyRequest(req, res) { } else { res.status(500).json({ "error": { - "message": "AI API request error", + "message": "proxyRequest: AI API request error", "code": "500" } }); @@ -214,7 +285,7 @@ async function proxyRequest(req, res) { /** * Process a single AI provider and its models * - * @param {Object} ctx - Operation context + * @param {operationContext.Context} ctx - Operation context * @param {Object} provider - Provider configuration * @returns {Promise} Processed provider with models or null if provider is invalid */ @@ -254,7 +325,7 @@ async function processProvider(ctx, provider) { /** * Retrieves all AI models from the configuration and dynamically from providers * - * @param {Object} ctx - Operation context + * @param {operationContext.Context} ctx - Operation context * @returns {Promise} Object containing providers and their models along with action configurations */ async function getPluginSettings(ctx) {