mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🧭 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
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:
parent
f3b165ea84
commit
b549966e4a
9 changed files with 444 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './csrf';
|
||||
export * from './tokens';
|
||||
export * from './validation';
|
||||
|
|
|
|||
233
packages/api/src/oauth/tokens.spec.ts
Normal file
233
packages/api/src/oauth/tokens.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
82
packages/api/src/oauth/validation.spec.ts
Normal file
82
packages/api/src/oauth/validation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
packages/api/src/oauth/validation.ts
Normal file
71
packages/api/src/oauth/validation.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue