🧭 fix: Tighten Action OAuth Endpoint Validation (#13142)
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled

* fix: tighten action OAuth endpoint validation

* fix: reuse action OAuth validation primitives

* fix: preserve action OAuth address exemptions
This commit is contained in:
Danny Avila 2026-05-15 14:52:43 -04:00
parent f3b165ea84
commit b549966e4a
9 changed files with 444 additions and 2 deletions

View file

@ -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,

View file

@ -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) {

View file

@ -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' });
}

View file

@ -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,

View file

@ -1,2 +1,3 @@
export * from './csrf';
export * from './tokens';
export * from './validation';

View file

@ -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<typeof axios>;
const mockedDecryptV2 = decryptV2 as jest.MockedFunction<typeof decryptV2>;
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<string, string>;
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<string, string>;
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');
});
});

View file

@ -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<string, ReturnType<typeof createSSRFSafeAgents>>();
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(),
});

View file

@ -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();
});
});

View file

@ -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<void> {
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<void> {
if (!auth || auth.type !== AuthTypeEnum.OAuth) {
return;
}
await validateActionOAuthEndpoint(auth.authorization_url, 'authorization_url', allowedAddresses);
await validateActionOAuthEndpoint(auth.client_url, 'client_url', allowedAddresses);
}