diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js index 806edc66cc..38d0dc8c94 100644 --- a/api/server/routes/actions.js +++ b/api/server/routes/actions.js @@ -107,6 +107,7 @@ router.get('/:action_id/oauth/callback', async (req, res) => { client_url: flowState.metadata.client_url, redirect_uri: flowState.metadata.redirect_uri, token_exchange_method: flowState.metadata.token_exchange_method, + allowedAddresses: flowState.metadata.allowedAddresses, /** Encrypted values */ encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id, encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret, diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index cccccedfd8..a775f67f3f 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,7 +1,11 @@ const express = require('express'); const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { generateCheckAccess, isActionDomainAllowed } = require('@librechat/api'); +const { + generateCheckAccess, + isActionDomainAllowed, + validateActionOAuthMetadata, +} = require('@librechat/api'); const { Permissions, ResourceType, @@ -155,6 +159,12 @@ router.post( metadata = { ...action.metadata, ...metadata }; } + try { + await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses); + } catch (error) { + return res.status(400).json({ message: error.message }); + } + const { actions: _actions = [], author: agent_author } = agent ?? {}; const actions = []; for (const action of _actions) { diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js index 7ddaffe5e7..7d35cc6a99 100644 --- a/api/server/routes/assistants/actions.js +++ b/api/server/routes/assistants/actions.js @@ -1,7 +1,7 @@ const express = require('express'); const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { isActionDomainAllowed } = require('@librechat/api'); +const { isActionDomainAllowed, validateActionOAuthMetadata } = require('@librechat/api'); const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider'); const { legacyDomainEncode, @@ -71,6 +71,12 @@ router.post('/:assistant_id', async (req, res) => { metadata = { ...action.metadata, ...metadata }; } + try { + await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses); + } catch (error) { + return res.status(400).json({ message: error.message }); + } + if (!assistant) { return res.status(404).json({ message: 'Assistant not found' }); } diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 859496bf7c..2e324b5312 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -9,6 +9,7 @@ const { refreshAccessToken, GenerationJobManager, createSSRFSafeAgents, + validateActionOAuthMetadata, } = require('@librechat/api'); const { Time, @@ -203,6 +204,8 @@ async function createActionTool({ if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) { try { if (metadata.auth.type === AuthTypeEnum.OAuth && metadata.auth.authorization_url) { + await validateActionOAuthMetadata(metadata.auth, allowedAddresses); + const action_id = action.action_id; const identifier = `${userId}:${action.action_id}`; const requestLogin = async () => { @@ -266,6 +269,7 @@ async function createActionTool({ client_url: metadata.auth.client_url, redirect_uri: `${process.env.DOMAIN_SERVER}/api/actions/${action_id}/oauth/callback`, token_exchange_method: metadata.auth.token_exchange_method, + allowedAddresses, /** Encrypted values */ encrypted_oauth_client_id: encrypted.oauth_client_id, encrypted_oauth_client_secret: encrypted.oauth_client_secret, @@ -328,6 +332,7 @@ async function createActionTool({ encrypted_oauth_client_id: encrypted.oauth_client_id, token_exchange_method: metadata.auth.token_exchange_method, encrypted_oauth_client_secret: encrypted.oauth_client_secret, + allowedAddresses, }, { findToken, diff --git a/packages/api/src/oauth/index.ts b/packages/api/src/oauth/index.ts index 01be92b6e3..f5a7a41334 100644 --- a/packages/api/src/oauth/index.ts +++ b/packages/api/src/oauth/index.ts @@ -1,2 +1,3 @@ export * from './csrf'; export * from './tokens'; +export * from './validation'; diff --git a/packages/api/src/oauth/tokens.spec.ts b/packages/api/src/oauth/tokens.spec.ts new file mode 100644 index 0000000000..f3e5b4a29e --- /dev/null +++ b/packages/api/src/oauth/tokens.spec.ts @@ -0,0 +1,233 @@ +import axios from 'axios'; +import { decryptV2 } from '@librechat/data-schemas'; +import { TokenExchangeMethodEnum } from 'librechat-data-provider'; +import type { AxiosRequestConfig } from 'axios'; +import { getAccessToken, refreshAccessToken } from './tokens'; + +jest.mock('axios'); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + }, + encryptV2: jest.fn(async (value: string) => `encrypted:${value}`), + decryptV2: jest.fn(async (value: string) => { + if (value === 'encrypted-client-id') { + return 'client-id'; + } + if (value === 'encrypted-client-secret') { + return 'client-secret'; + } + if (value === 'encrypted-refresh-token') { + return 'refresh-token'; + } + return value; + }), +})); + +const mockedAxios = axios as jest.MockedFunction; +const mockedDecryptV2 = decryptV2 as jest.MockedFunction; + +function createTokenMethods() { + return { + findToken: jest.fn().mockResolvedValue(null), + updateToken: jest.fn().mockResolvedValue({}), + createToken: jest.fn().mockResolvedValue({}), + }; +} + +function getAxiosConfig(): AxiosRequestConfig { + const config = mockedAxios.mock.calls[0]?.[0] as AxiosRequestConfig | undefined; + if (!config) { + throw new Error('Expected axios to be called'); + } + return config; +} + +describe('action OAuth token exchange validation', () => { + const tokenResponse = { + access_token: 'access-token', + expires_in: 3600, + refresh_token: 'new-refresh-token', + refresh_token_expires_in: 7200, + }; + + const baseFields = { + userId: 'user-1', + identifier: 'user-1:action-1', + client_url: 'https://93.184.216.34/oauth/token', + encrypted_oauth_client_id: 'encrypted-client-id', + encrypted_oauth_client_secret: 'encrypted-client-secret', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.mockResolvedValue({ data: tokenResponse }); + }); + + it.each([ + 'http://93.184.216.34/oauth/token', + 'https://localhost/oauth/token', + 'https://10.0.0.1/oauth/token', + 'https://169.254.169.254/latest/meta-data', + ])( + 'rejects unsafe client_url before decrypting secrets or calling axios: %s', + async (clientUrl) => { + await expect( + getAccessToken( + { + ...baseFields, + client_url: clientUrl, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ), + ).rejects.toThrow(/Invalid action OAuth client_url/); + + expect(mockedDecryptV2).not.toHaveBeenCalled(); + expect(mockedAxios).not.toHaveBeenCalled(); + }, + ); + + it('posts authorization-code exchanges without following redirects', async () => { + await getAccessToken( + { + ...baseFields, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const params = new URLSearchParams(config.data as string); + + expect(config).toEqual( + expect.objectContaining({ + method: 'POST', + url: baseFields.client_url, + maxRedirects: 0, + httpsAgent: expect.any(Object), + }), + ); + expect(params.get('client_id')).toBe('client-id'); + expect(params.get('client_secret')).toBe('client-secret'); + }); + + it('uses Basic auth for authorization-code exchanges without putting client secrets in the body', async () => { + await getAccessToken( + { + ...baseFields, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.BasicAuthHeader, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const headers = config.headers as Record; + const params = new URLSearchParams(config.data as string); + + expect(config.maxRedirects).toBe(0); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from('client-id:client-secret').toString('base64')}`, + ); + expect(params.has('client_id')).toBe(false); + expect(params.has('client_secret')).toBe(false); + }); + + it('posts refresh-token exchanges without following redirects', async () => { + await refreshAccessToken( + { + ...baseFields, + refresh_token: 'refresh-token', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const params = new URLSearchParams(config.data as string); + + expect(config).toEqual( + expect.objectContaining({ + method: 'POST', + url: baseFields.client_url, + maxRedirects: 0, + httpsAgent: expect.any(Object), + }), + ); + expect(params.get('grant_type')).toBe('refresh_token'); + expect(params.get('client_id')).toBe('client-id'); + expect(params.get('client_secret')).toBe('client-secret'); + }); + + it('uses Basic auth for refresh-token exchanges without putting client secrets in the body', async () => { + await refreshAccessToken( + { + ...baseFields, + refresh_token: 'refresh-token', + token_exchange_method: TokenExchangeMethodEnum.BasicAuthHeader, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const headers = config.headers as Record; + const params = new URLSearchParams(config.data as string); + + expect(config.maxRedirects).toBe(0); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from('client-id:client-secret').toString('base64')}`, + ); + expect(params.has('client_id')).toBe(false); + expect(params.has('client_secret')).toBe(false); + }); + + it('reuses the same HTTPS agent across token exchanges', async () => { + await getAccessToken( + { + ...baseFields, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + await refreshAccessToken( + { + ...baseFields, + refresh_token: 'refresh-token', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + + const [accessConfig, refreshConfig] = mockedAxios.mock.calls.map( + ([config]) => config as AxiosRequestConfig, + ); + + expect(accessConfig.httpsAgent).toBe(refreshConfig.httpsAgent); + }); + + it('allows explicitly exempted private token endpoints', async () => { + await getAccessToken( + { + ...baseFields, + client_url: 'https://10.0.0.1/oauth/token', + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + allowedAddresses: ['10.0.0.1:443'], + }, + createTokenMethods(), + ); + + expect(getAxiosConfig().url).toBe('https://10.0.0.1/oauth/token'); + }); +}); diff --git a/packages/api/src/oauth/tokens.ts b/packages/api/src/oauth/tokens.ts index e51e91b842..596860bf58 100644 --- a/packages/api/src/oauth/tokens.ts +++ b/packages/api/src/oauth/tokens.ts @@ -3,7 +3,28 @@ import { logger, encryptV2, decryptV2 } from '@librechat/data-schemas'; import { TokenExchangeMethodEnum } from 'librechat-data-provider'; import type { TokenMethods } from '@librechat/data-schemas'; import type { AxiosError } from 'axios'; +import { createSSRFSafeAgents } from '~/auth'; import { logAxiosError } from '~/utils'; +import { validateActionOAuthEndpoint } from './validation'; + +const actionOAuthAgents = createSSRFSafeAgents(); +const actionOAuthAgentsByAddress = new Map>(); + +function getActionOAuthAgents(allowedAddresses?: string[] | null) { + if (!Array.isArray(allowedAddresses) || allowedAddresses.length === 0) { + return actionOAuthAgents; + } + + const cacheKey = allowedAddresses.join('\n'); + const cachedAgents = actionOAuthAgentsByAddress.get(cacheKey); + if (cachedAgents) { + return cachedAgents; + } + + const agents = createSSRFSafeAgents(allowedAddresses); + actionOAuthAgentsByAddress.set(cacheKey, agents); + return agents; +} export function createHandleOAuthToken({ findToken, @@ -143,6 +164,7 @@ export async function refreshAccessToken( token_exchange_method, encrypted_oauth_client_id, encrypted_oauth_client_secret, + allowedAddresses, }: { userId: string; client_url: string; @@ -151,6 +173,7 @@ export async function refreshAccessToken( token_exchange_method: TokenExchangeMethodEnum; encrypted_oauth_client_id: string; encrypted_oauth_client_secret: string; + allowedAddresses?: string[] | null; }, { findToken, @@ -167,6 +190,8 @@ export async function refreshAccessToken( refresh_token?: string; refresh_token_expires_in?: number; }> { + await validateActionOAuthEndpoint(client_url, 'client_url', allowedAddresses); + try { const oauth_client_id = await decryptV2(encrypted_oauth_client_id); const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); @@ -193,6 +218,8 @@ export async function refreshAccessToken( method: 'POST', url: client_url, headers, + maxRedirects: 0, + httpsAgent: getActionOAuthAgents(allowedAddresses).httpsAgent, data: params.toString(), }); await processAccessTokens( @@ -242,6 +269,7 @@ export async function getAccessToken( token_exchange_method, encrypted_oauth_client_id, encrypted_oauth_client_secret, + allowedAddresses, }: { code: string; userId: string; @@ -251,6 +279,7 @@ export async function getAccessToken( token_exchange_method: TokenExchangeMethodEnum; encrypted_oauth_client_id: string; encrypted_oauth_client_secret: string; + allowedAddresses?: string[] | null; }, { findToken, @@ -267,6 +296,8 @@ export async function getAccessToken( refresh_token?: string; refresh_token_expires_in?: number; }> { + await validateActionOAuthEndpoint(client_url, 'client_url', allowedAddresses); + const oauth_client_id = await decryptV2(encrypted_oauth_client_id); const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); @@ -294,6 +325,8 @@ export async function getAccessToken( method: 'POST', url: client_url, headers, + maxRedirects: 0, + httpsAgent: getActionOAuthAgents(allowedAddresses).httpsAgent, data: params.toString(), }); diff --git a/packages/api/src/oauth/validation.spec.ts b/packages/api/src/oauth/validation.spec.ts new file mode 100644 index 0000000000..fc28801efd --- /dev/null +++ b/packages/api/src/oauth/validation.spec.ts @@ -0,0 +1,82 @@ +import { AuthTypeEnum } from 'librechat-data-provider'; + +import { validateActionOAuthEndpoint, validateActionOAuthMetadata } from './validation'; + +describe('validateActionOAuthEndpoint', () => { + it('allows HTTPS endpoints on public addresses', async () => { + await expect( + validateActionOAuthEndpoint('https://93.184.216.34/oauth/token', 'client_url'), + ).resolves.toBeUndefined(); + }); + + it.each([ + ['HTTP endpoint', 'http://93.184.216.34/oauth/token'], + ['FTP endpoint', 'ftp://93.184.216.34/oauth/token'], + ['localhost', 'https://localhost/oauth/token'], + ['loopback IP', 'https://127.0.0.1/oauth/token'], + ['private IP', 'https://10.0.0.1/oauth/token'], + ['link-local IP', 'https://169.254.169.123/oauth/token'], + ['metadata IP', 'https://169.254.169.254/latest/meta-data'], + ['internal hostname', 'https://metadata/oauth/token'], + ['IPv6 loopback', 'https://[::1]/oauth/token'], + ])('rejects %s', async (_label, url) => { + await expect(validateActionOAuthEndpoint(url, 'client_url')).rejects.toThrow( + /Invalid action OAuth client_url/, + ); + }); + + it('rejects unparseable endpoint URLs', async () => { + await expect(validateActionOAuthEndpoint('not a url', 'authorization_url')).rejects.toThrow( + /Invalid action OAuth authorization_url/, + ); + }); + + it('allows restricted HTTPS endpoints when explicitly exempted by allowedAddresses', async () => { + await expect( + validateActionOAuthEndpoint('https://10.0.0.1/oauth/token', 'client_url', ['10.0.0.1:443']), + ).resolves.toBeUndefined(); + }); + + it('keeps allowedAddresses scoped to the endpoint port', async () => { + await expect( + validateActionOAuthEndpoint('https://10.0.0.1:8443/oauth/token', 'client_url', [ + '10.0.0.1:443', + ]), + ).rejects.toThrow(/Invalid action OAuth client_url/); + }); +}); + +describe('validateActionOAuthMetadata', () => { + it('validates both OAuth authorization and token endpoints', async () => { + await expect( + validateActionOAuthMetadata({ + type: AuthTypeEnum.OAuth, + authorization_url: 'https://93.184.216.34/oauth/authorize', + client_url: 'https://10.0.0.1/oauth/token', + }), + ).rejects.toThrow(/Invalid action OAuth client_url/); + }); + + it('passes allowedAddresses to both OAuth endpoints', async () => { + await expect( + validateActionOAuthMetadata( + { + type: AuthTypeEnum.OAuth, + authorization_url: 'https://10.0.0.1/oauth/authorize', + client_url: 'https://10.0.0.1/oauth/token', + }, + ['10.0.0.1:443'], + ), + ).resolves.toBeUndefined(); + }); + + it('ignores non-OAuth auth metadata', async () => { + await expect( + validateActionOAuthMetadata({ + type: AuthTypeEnum.ServiceHttp, + authorization_url: 'http://localhost/oauth/authorize', + client_url: 'http://localhost/oauth/token', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/api/src/oauth/validation.ts b/packages/api/src/oauth/validation.ts new file mode 100644 index 0000000000..d551248800 --- /dev/null +++ b/packages/api/src/oauth/validation.ts @@ -0,0 +1,71 @@ +import { AuthTypeEnum } from 'librechat-data-provider'; + +import { validateEndpointURL } from '~/auth'; + +type ActionOAuthEndpointField = 'authorization_url' | 'client_url'; + +interface ActionOAuthAuthMetadata { + type?: AuthTypeEnum | string | null; + authorization_url?: string | null; + client_url?: string | null; +} + +function invalidActionOAuth(fieldName: ActionOAuthEndpointField, message: string): never { + throw new Error(`Invalid action OAuth ${fieldName}: ${message}`); +} + +function parseEndpointError(error: unknown): string { + if (!(error instanceof Error)) { + return 'endpoint URL is not permitted.'; + } + + try { + const parsed = JSON.parse(error.message) as { message?: unknown }; + if (typeof parsed.message === 'string') { + return parsed.message; + } + } catch { + return error.message; + } + + return error.message; +} + +export async function validateActionOAuthEndpoint( + url: string | null | undefined, + fieldName: ActionOAuthEndpointField, + allowedAddresses?: string[] | null, +): Promise { + if (!url || typeof url !== 'string') { + invalidActionOAuth(fieldName, 'endpoint URL is required.'); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + invalidActionOAuth(fieldName, 'unable to parse endpoint URL.'); + } + + if (parsedUrl.protocol !== 'https:') { + invalidActionOAuth(fieldName, 'only HTTPS endpoint URLs are permitted.'); + } + + try { + await validateEndpointURL(url, `action OAuth ${fieldName}`, allowedAddresses); + } catch (error) { + invalidActionOAuth(fieldName, parseEndpointError(error)); + } +} + +export async function validateActionOAuthMetadata( + auth?: ActionOAuthAuthMetadata | null, + allowedAddresses?: string[] | null, +): Promise { + if (!auth || auth.type !== AuthTypeEnum.OAuth) { + return; + } + + await validateActionOAuthEndpoint(auth.authorization_url, 'authorization_url', allowedAddresses); + await validateActionOAuthEndpoint(auth.client_url, 'client_url', allowedAddresses); +}