mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-30 21:49:13 -05:00
Compare commits
6 Commits
v0.10.6
...
copilot/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8f58bca9 | ||
|
|
fd3972bda2 | ||
|
|
68c454b4b6 | ||
|
|
9bcc96f207 | ||
|
|
259241f295 | ||
|
|
c88429934c |
@@ -1,3 +1,54 @@
|
||||
# Security Summary - MCPHub Security Fixes
|
||||
|
||||
## Recent Security Fixes
|
||||
|
||||
### Authentication Bypass Vulnerability (FIXED - 2025-11-23)
|
||||
|
||||
✅ **CRITICAL FIX APPLIED**: Authentication bypass vulnerability in MCP transport endpoints
|
||||
|
||||
**Vulnerability Details:**
|
||||
- **Severity**: Critical (CVSS 9.8 - Unauthenticated Remote Access)
|
||||
- **Affected Versions**: All versions prior to this fix
|
||||
- **CVE**: Pending assignment
|
||||
- **Discovery**: Security researcher report
|
||||
- **Status**: ✅ FIXED
|
||||
|
||||
**Issue:**
|
||||
The MCP transport endpoints (`/:user/mcp/:group` and `/:user/sse/:group`) accepted requests without verifying credentials. An attacker could impersonate any user by simply placing their username in the URL path, bypassing all authentication and accessing privileged MCP operations.
|
||||
|
||||
**Root Cause:**
|
||||
- `validateBearerAuth()` in `sseService.ts` was using `loadSettings()` which filters settings based on user context
|
||||
- `DataServicex.filterSettings()` replaces `systemConfig` with user-specific config for non-admin users
|
||||
- This caused the global `enableBearerAuth` configuration to be unavailable during validation
|
||||
- Result: Bearer authentication was never enforced, even when explicitly enabled in configuration
|
||||
|
||||
**Impact:**
|
||||
An unauthenticated attacker could:
|
||||
- Impersonate any user account
|
||||
- Access private MCP server groups
|
||||
- Execute privileged MCP tool operations
|
||||
- Exfiltrate secrets or data from configured MCP servers (Slack bots, kubectl, databases, etc.)
|
||||
|
||||
**Fix Applied:**
|
||||
- Changed `validateBearerAuth()` to use `loadOriginalSettings()` instead of `loadSettings()`
|
||||
- This ensures bearer auth validation always has access to the actual global systemConfig
|
||||
- Updated all test mocks to properly test authentication
|
||||
|
||||
**Verification:**
|
||||
- ✅ 16 new security tests added to prevent regression
|
||||
- ✅ All 204 tests passing
|
||||
- ✅ Unauthenticated requests now return 401 Unauthorized
|
||||
- ✅ Bearer auth properly enforced when enabled
|
||||
- ✅ Proper WWW-Authenticate headers returned
|
||||
|
||||
**Remediation:**
|
||||
- Update to the latest version immediately
|
||||
- Review access logs for suspicious activity
|
||||
- Ensure `enableBearerAuth: true` is set in production
|
||||
- Use a strong `bearerAuthKey` value
|
||||
|
||||
---
|
||||
|
||||
# Security Summary - OAuth Authorization Server Implementation
|
||||
|
||||
## Overview
|
||||
@@ -183,5 +234,11 @@ The OAuth 2.0 authorization server implementation in MCPHub follows security bes
|
||||
|
||||
**Overall Security Assessment**: ✅ **SECURE** with production hardening recommendations
|
||||
|
||||
**Last Updated**: 2025-11-02
|
||||
**Last Updated**: 2025-11-23
|
||||
**Next Review**: Recommended quarterly or after major changes
|
||||
|
||||
## Recent Security Audit Results
|
||||
|
||||
- ✅ **Authentication Bypass**: FIXED (2025-11-23)
|
||||
- ✅ **OAuth 2.0 Implementation**: Secure with noted limitations
|
||||
- ⚠️ **Rate Limiting**: Recommendation for production deployment
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
export const PERMISSIONS = {
|
||||
// Settings page permissions
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
|
||||
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { Copy, Check, Download } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempOAuthServerConfig, setTempOAuthServerConfig] = useState<{
|
||||
accessTokenLifetime: string;
|
||||
refreshTokenLifetime: string;
|
||||
authorizationCodeLifetime: string;
|
||||
allowedScopes: string;
|
||||
dynamicRegistrationAllowedGrantTypes: string;
|
||||
accessTokenLifetime: string
|
||||
refreshTokenLifetime: string
|
||||
authorizationCodeLifetime: string
|
||||
allowedScopes: string
|
||||
dynamicRegistrationAllowedGrantTypes: string
|
||||
}>({
|
||||
accessTokenLifetime: '3600',
|
||||
refreshTokenLifetime: '1209600',
|
||||
authorizationCodeLifetime: '300',
|
||||
allowedScopes: 'read, write',
|
||||
dynamicRegistrationAllowedGrantTypes: 'authorization_code, refresh_token',
|
||||
});
|
||||
})
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -86,14 +86,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData();
|
||||
} = useSettingsData()
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig);
|
||||
setInstallConfig(savedInstallConfig)
|
||||
}
|
||||
}, [savedInstallConfig]);
|
||||
}, [savedInstallConfig])
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -103,9 +103,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [smartRoutingConfig]);
|
||||
}, [smartRoutingConfig])
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -115,9 +115,9 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
})
|
||||
}
|
||||
}, [mcpRouterConfig]);
|
||||
}, [mcpRouterConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthServerConfig) {
|
||||
@@ -138,18 +138,18 @@ const SettingsPage: React.FC = () => {
|
||||
oauthServerConfig.allowedScopes && oauthServerConfig.allowedScopes.length > 0
|
||||
? oauthServerConfig.allowedScopes.join(', ')
|
||||
: '',
|
||||
dynamicRegistrationAllowedGrantTypes: oauthServerConfig.dynamicRegistration
|
||||
?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
});
|
||||
dynamicRegistrationAllowedGrantTypes:
|
||||
oauthServerConfig.dynamicRegistration?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
})
|
||||
}
|
||||
}, [oauthServerConfig]);
|
||||
}, [oauthServerConfig])
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -160,7 +160,7 @@ const SettingsPage: React.FC = () => {
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
});
|
||||
})
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
@@ -176,8 +176,8 @@ const SettingsPage: React.FC = () => {
|
||||
setSectionsVisible((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
@@ -191,39 +191,39 @@ const SettingsPage: React.FC = () => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
});
|
||||
})
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}));
|
||||
}))
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
@@ -232,12 +232,12 @@ const SettingsPage: React.FC = () => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
@@ -246,14 +246,14 @@ const SettingsPage: React.FC = () => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
@@ -262,24 +262,24 @@ const SettingsPage: React.FC = () => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
|
||||
type OAuthServerNumberField =
|
||||
| 'accessTokenLifetime'
|
||||
| 'refreshTokenLifetime'
|
||||
| 'authorizationCodeLifetime';
|
||||
| 'authorizationCodeLifetime'
|
||||
|
||||
const handleOAuthServerNumberChange = (key: OAuthServerNumberField, value: string) => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const handleOAuthServerTextChange = (
|
||||
key: 'allowedScopes' | 'dynamicRegistrationAllowedGrantTypes',
|
||||
@@ -288,52 +288,52 @@ const SettingsPage: React.FC = () => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
}))
|
||||
}
|
||||
|
||||
const saveOAuthServerNumberConfig = async (key: OAuthServerNumberField) => {
|
||||
const rawValue = tempOAuthServerConfig[key];
|
||||
const rawValue = tempOAuthServerConfig[key]
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const parsedValue = Number(rawValue);
|
||||
const parsedValue = Number(rawValue)
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
await updateOAuthServerConfig(key, parsedValue);
|
||||
};
|
||||
await updateOAuthServerConfig(key, parsedValue)
|
||||
}
|
||||
|
||||
const saveOAuthServerAllowedScopes = async () => {
|
||||
const scopes = tempOAuthServerConfig.allowedScopes
|
||||
.split(',')
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0);
|
||||
.filter((scope) => scope.length > 0)
|
||||
|
||||
await updateOAuthServerConfig('allowedScopes', scopes);
|
||||
};
|
||||
await updateOAuthServerConfig('allowedScopes', scopes)
|
||||
}
|
||||
|
||||
const saveOAuthServerGrantTypes = async () => {
|
||||
const grantTypes = tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes
|
||||
.split(',')
|
||||
.map((grant) => grant.trim())
|
||||
.filter((grant) => grant.length > 0);
|
||||
.filter((grant) => grant.length > 0)
|
||||
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
allowedGrantTypes: grantTypes,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const handleOAuthServerToggle = async (
|
||||
key: 'enabled' | 'requireClientSecret' | 'requireState',
|
||||
value: boolean,
|
||||
) => {
|
||||
await updateOAuthServerConfig(key, value);
|
||||
};
|
||||
await updateOAuthServerConfig(key, value)
|
||||
}
|
||||
|
||||
const handleDynamicRegistrationToggle = async (
|
||||
updates: Partial<typeof oauthServerConfig.dynamicRegistration>,
|
||||
@@ -341,137 +341,137 @@ const SettingsPage: React.FC = () => {
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
...updates,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value };
|
||||
const updates: any = { enabled: value }
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings();
|
||||
console.log('Fetched MCP settings:', result);
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
setMcpSettingsJson(configJson);
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error);
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings();
|
||||
fetchMcpSettings()
|
||||
}
|
||||
}, [sectionsVisible.exportConfig]);
|
||||
}, [sectionsVisible.exportConfig])
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return;
|
||||
if (!mcpSettingsJson) return
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson);
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = mcpSettingsJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
console.error('Copy to clipboard failed:', err);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return;
|
||||
if (!mcpSettingsJson) return
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'mcp_settings.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
|
||||
};
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -643,7 +643,9 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.requireClientSecret')}</h3>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.requireClientSecret')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.requireClientSecretDescription')}
|
||||
</p>
|
||||
@@ -671,7 +673,9 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.accessTokenLifetime')}</h3>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.accessTokenLifetime')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.accessTokenLifetimeDescription')}
|
||||
</p>
|
||||
@@ -760,7 +764,9 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.allowedScopes')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.allowedScopesDescription')}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.allowedScopesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -940,154 +946,142 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* System Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SYSTEM_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{sectionsVisible.nameSeparator && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempNameSeparator}
|
||||
onChange={(e) => setTempNameSeparator(e.target.value)}
|
||||
placeholder="-"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
/>
|
||||
<button
|
||||
onClick={saveNameSeparator}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSessionRebuild')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSessionRebuildDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempNameSeparator}
|
||||
onChange={(e) => setTempNameSeparator(e.target.value)}
|
||||
placeholder="-"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveNameSeparator}
|
||||
disabled={loading}
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableSessionRebuild')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSessionRebuildDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_ROUTE_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableBearerAuthDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.bearerAuthKeyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableGroupNameRoute')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
@@ -1099,10 +1093,10 @@ const SettingsPage: React.FC = () => {
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
@@ -1194,10 +1188,7 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div
|
||||
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
|
||||
data-section="password"
|
||||
>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
@@ -1267,7 +1258,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
export default SettingsPage
|
||||
|
||||
@@ -41,5 +41,27 @@
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"requireState": false,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -543,7 +543,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild !== 'boolean';
|
||||
|
||||
const hasOAuthServerUpdate =
|
||||
oauthServer &&
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function exampleUserConfigOperations() {
|
||||
console.log('All user configs:', Object.keys(allUserConfigs));
|
||||
|
||||
// Get specific section for user
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing' as never);
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
|
||||
console.log('Admin routing config:', userRoutingConfig);
|
||||
|
||||
// Delete user configuration
|
||||
|
||||
13
src/services/dataService.test.ts
Normal file
13
src/services/dataService.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import './services.js';
|
||||
|
||||
describe('DataService', () => {
|
||||
test('should get default implementation and call foo method', async () => {
|
||||
const dataService: DataService = await getDataService();
|
||||
const consoleSpy = jest.spyOn(console, 'log');
|
||||
dataService.foo();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +1,31 @@
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
|
||||
export class DataService {
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[], user?: IUser): any[];
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
// TODO: apply userConfig to filter settings as needed
|
||||
// const userConfig = settings.userConfigs?.[currentUser?.username || ''];
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
filterData(data: any[], _user?: IUser): any[] {
|
||||
return data;
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
// TODO: only allow modifying certain fields based on userConfig permissions
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
|
||||
return settings;
|
||||
}
|
||||
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
}
|
||||
|
||||
75
src/services/dataServicex.ts
Normal file
75
src/services/dataServicex.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { IUser, McpSettings, UserConfig } from '../types/index.js';
|
||||
import { DataService } from './dataService.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
|
||||
export class DataServicex implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
|
||||
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
|
||||
enableBearerAuth: systemConfig.routing.enableBearerAuth,
|
||||
bearerAuthKey: systemConfig.routing.bearerAuthKey,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
@@ -11,24 +11,7 @@ interface Service<T> {
|
||||
const registry = new Map<string, Service<any>>();
|
||||
const instances = new Map<string, unknown>();
|
||||
|
||||
async function tryLoadOverride<T>(key: string, overridePath: string): Promise<Class<T> | undefined> {
|
||||
try {
|
||||
const moduleUrl = pathToFileURL(overridePath).href;
|
||||
const mod = await import(moduleUrl);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
return override as Class<T>;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ignore not-found errors and keep trying other paths; surface other errors for visibility
|
||||
if (error?.code !== 'ERR_MODULE_NOT_FOUND' && error?.code !== 'MODULE_NOT_FOUND') {
|
||||
console.warn(`Failed to load service override from ${overridePath}:`, error);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function registerService<T>(key: string, entry: Service<T>) {
|
||||
export function registerService<T>(key: string, entry: Service<T>) {
|
||||
// Try to load override immediately during registration
|
||||
// Try multiple paths and file extensions in order
|
||||
const serviceDirs = ['src/services', 'dist/services'];
|
||||
@@ -39,10 +22,18 @@ export async function registerService<T>(key: string, entry: Service<T>) {
|
||||
for (const fileExt of fileExts) {
|
||||
const overridePath = join(process.cwd(), serviceDir, overrideFileName + fileExt);
|
||||
|
||||
const override = await tryLoadOverride<T>(key, overridePath);
|
||||
if (override) {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
try {
|
||||
// Use createRequire with a stable path reference
|
||||
const require = createRequire(join(process.cwd(), 'package.json'));
|
||||
const mod = require(overridePath);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue trying next path/extension combination
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { registerService, getService } from './registry.js';
|
||||
import { DataService, DataServiceImpl } from './dataService.js';
|
||||
|
||||
registerService('dataService', {
|
||||
defaultImpl: DataServiceImpl,
|
||||
});
|
||||
|
||||
export function getDataService(): DataService {
|
||||
return new DataService();
|
||||
return getService<DataService>('dataService');
|
||||
}
|
||||
|
||||
@@ -22,21 +22,23 @@ jest.mock('../config/index.js', () => {
|
||||
const config = {
|
||||
basePath: '/test',
|
||||
};
|
||||
const mockSettings = {
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
},
|
||||
};
|
||||
return {
|
||||
__esModule: true,
|
||||
default: config,
|
||||
loadSettings: jest.fn(() => ({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
},
|
||||
})),
|
||||
loadSettings: jest.fn(() => mockSettings),
|
||||
loadOriginalSettings: jest.fn(() => mockSettings),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,7 +68,7 @@ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
|
||||
// Import mocked modules
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -152,12 +154,19 @@ const expectBearerUnauthorized = (
|
||||
);
|
||||
};
|
||||
|
||||
const setMockSettings = (settings: any): void => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue(settings);
|
||||
(loadOriginalSettings as jest.MockedFunction<typeof loadOriginalSettings>).mockReturnValue(
|
||||
settings,
|
||||
);
|
||||
};
|
||||
|
||||
describe('sseService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset settings cache
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
const mockSettingsValue = {
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -168,7 +177,11 @@ describe('sseService', () => {
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
},
|
||||
});
|
||||
};
|
||||
setMockSettings(mockSettingsValue);
|
||||
(loadOriginalSettings as jest.MockedFunction<typeof loadOriginalSettings>).mockReturnValue(
|
||||
mockSettingsValue,
|
||||
);
|
||||
});
|
||||
|
||||
describe('bearer authentication', () => {
|
||||
@@ -185,7 +198,7 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
const mockSettingsValue = {
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -195,7 +208,11 @@ describe('sseService', () => {
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
setMockSettings(mockSettingsValue);
|
||||
(loadOriginalSettings as jest.MockedFunction<typeof loadOriginalSettings>).mockReturnValue(
|
||||
mockSettingsValue,
|
||||
);
|
||||
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
@@ -206,7 +223,7 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled with invalid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -229,7 +246,7 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should pass when bearer auth is enabled with valid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -279,7 +296,7 @@ describe('sseService', () => {
|
||||
|
||||
describe('handleSseConnection', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -375,7 +392,7 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -400,7 +417,7 @@ describe('sseService', () => {
|
||||
|
||||
describe('handleMcpPostRequest', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -463,7 +480,7 @@ describe('sseService', () => {
|
||||
|
||||
it('should transparently rebuild invalid session when enabled', async () => {
|
||||
// Enable session rebuild for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -492,7 +509,7 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -533,7 +550,7 @@ describe('sseService', () => {
|
||||
Object.keys(transports).forEach(key => delete transports[key]);
|
||||
|
||||
// Enable bearer auth for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -570,7 +587,7 @@ describe('sseService', () => {
|
||||
|
||||
it('should transparently rebuild invalid session in handleMcpOtherRequest when enabled', async () => {
|
||||
// Enable bearer auth and session rebuild for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
@@ -602,7 +619,7 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
setMockSettings({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
@@ -31,7 +31,8 @@ type BearerAuthResult =
|
||||
};
|
||||
|
||||
const validateBearerAuth = (req: Request): BearerAuthResult => {
|
||||
const settings = loadSettings();
|
||||
// Use original settings to get the actual systemConfig, not filtered by user context
|
||||
const settings = loadOriginalSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
|
||||
@@ -175,7 +175,14 @@ export interface SystemConfig {
|
||||
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
|
||||
}
|
||||
|
||||
export interface UserConfig {}
|
||||
export interface UserConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
}
|
||||
|
||||
// OAuth Client for MCPHub's own authorization server
|
||||
export interface IOAuthClient {
|
||||
|
||||
439
tests/security/auth-bypass.test.ts
Normal file
439
tests/security/auth-bypass.test.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Security tests for authentication bypass vulnerability
|
||||
*
|
||||
* This test suite verifies that the MCP transport endpoints properly authenticate users
|
||||
* and prevent unauthorized access through user impersonation.
|
||||
*
|
||||
* Vulnerability description:
|
||||
* - User-scoped routes (/:user/mcp/:group and /:user/sse/:group) trust the path segment
|
||||
* - No validation that the caller has permission to access that user's resources
|
||||
* - Bearer auth configuration (enableBearerAuth) is not properly enforced
|
||||
*/
|
||||
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { AppServer } from '../../src/server.js';
|
||||
import { TestServerHelper } from '../utils/testServerHelper.js';
|
||||
import { createMockSettings } from '../utils/mockSettings.js';
|
||||
import { cleanupAllServers } from '../../src/services/mcpService.js';
|
||||
import { McpSettings, IUser } from '../../src/types/index.js';
|
||||
|
||||
describe('Authentication Bypass Security Tests', () => {
|
||||
let _appServer: AppServer;
|
||||
let httpServer: Server;
|
||||
let _baseURL: string;
|
||||
let testServerHelper: TestServerHelper;
|
||||
|
||||
// Test users defined in settings
|
||||
const adminUser: IUser = {
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
const regularUser: IUser = {
|
||||
username: 'bob',
|
||||
password: 'bob123',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
const aliceUser: IUser = {
|
||||
username: 'alice',
|
||||
password: 'alice123',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create mock settings with multiple users and bearer auth enabled
|
||||
const settings: McpSettings = createMockSettings({
|
||||
users: [adminUser, regularUser, aliceUser],
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'supersecret-value',
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
},
|
||||
mcpServers: {
|
||||
'alice-secret': {
|
||||
command: 'npx',
|
||||
args: ['-y', 'time-mcp'],
|
||||
env: {},
|
||||
enabled: true,
|
||||
keepAliveInterval: 30000,
|
||||
type: 'stdio',
|
||||
},
|
||||
'bob-secret': {
|
||||
command: 'npx',
|
||||
args: ['-y', 'time-mcp'],
|
||||
env: {},
|
||||
enabled: true,
|
||||
keepAliveInterval: 30000,
|
||||
type: 'stdio',
|
||||
},
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
name: 'alice-private',
|
||||
servers: ['alice-secret'],
|
||||
description: 'Alice private group',
|
||||
owner: 'alice',
|
||||
},
|
||||
{
|
||||
name: 'bob-private',
|
||||
servers: ['bob-secret'],
|
||||
description: 'Bob private group',
|
||||
owner: 'bob',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
testServerHelper = new TestServerHelper();
|
||||
const result = await testServerHelper.createTestServer(settings);
|
||||
|
||||
_appServer = result.appServer;
|
||||
httpServer = result.httpServer;
|
||||
_baseURL = result.baseURL;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
cleanupAllServers();
|
||||
|
||||
if (testServerHelper) {
|
||||
await testServerHelper.closeTestServer();
|
||||
} else if (httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
|
||||
describe('User-Scoped MCP Endpoint - Unauthenticated Access', () => {
|
||||
it('should reject unauthenticated POST to /:user/mcp/:group (impersonation attempt)', async () => {
|
||||
// Attempt to initialize MCP session as admin without authentication
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/mcp/alice-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json, text/event-stream')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'test-client',
|
||||
version: '1.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Should reject with 401 Unauthorized
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toBe('invalid_token');
|
||||
expect(response.headers['www-authenticate']).toContain('Bearer');
|
||||
});
|
||||
|
||||
it('should reject unauthenticated POST to /:user/mcp/:group for different user', async () => {
|
||||
// Attempt to impersonate bob
|
||||
const response = await request(httpServer)
|
||||
.post('/bob/mcp/bob-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'attacker',
|
||||
version: '1.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toBe('invalid_token');
|
||||
});
|
||||
|
||||
it('should reject unauthenticated tools/call after session creation', async () => {
|
||||
// This test verifies that even if a session is somehow obtained,
|
||||
// subsequent calls without auth should also be rejected
|
||||
|
||||
// First, try to create a session without auth (should fail)
|
||||
const initResponse = await request(httpServer)
|
||||
.post('/alice/mcp/alice-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(initResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User-Scoped SSE Endpoint - Unauthenticated Access', () => {
|
||||
it('should reject unauthenticated GET to /:user/sse/:group', async () => {
|
||||
const response = await request(httpServer)
|
||||
.get('/admin/sse/alice-private')
|
||||
.set('Accept', 'text/event-stream');
|
||||
|
||||
// Should reject with 401 Unauthorized
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toBe('invalid_token');
|
||||
expect(response.headers['www-authenticate']).toContain('Bearer');
|
||||
});
|
||||
|
||||
it('should reject unauthenticated GET to /:user/sse/:group for different user', async () => {
|
||||
const response = await request(httpServer)
|
||||
.get('/bob/sse/bob-private')
|
||||
.set('Accept', 'text/event-stream');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bearer Auth Enforcement with enableBearerAuth=true', () => {
|
||||
it('should accept valid bearer token', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/mcp/alice-private')
|
||||
.set('Authorization', 'Bearer supersecret-value')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
// With valid bearer token, should NOT return 401 (auth error)
|
||||
// May return other errors (404, 406, etc.) depending on MCP server state
|
||||
expect(response.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it('should reject invalid bearer token', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/mcp/alice-private')
|
||||
.set('Authorization', 'Bearer wrong-token')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('invalid_token');
|
||||
expect(response.body.error_description).toContain('Invalid bearer token');
|
||||
});
|
||||
|
||||
it('should reject malformed Authorization header', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/mcp/alice-private')
|
||||
.set('Authorization', 'InvalidFormat token')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should enforce bearer auth on SSE endpoints', async () => {
|
||||
const response = await request(httpServer)
|
||||
.get('/admin/sse/alice-private')
|
||||
.set('Accept', 'text/event-stream');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('invalid_token');
|
||||
});
|
||||
|
||||
it.skip('should accept valid bearer token on SSE endpoints (skipped - SSE keeps connection open)', async () => {
|
||||
const response = await request(httpServer)
|
||||
.get('/admin/sse/alice-private')
|
||||
.set('Authorization', 'Bearer supersecret-value')
|
||||
.set('Accept', 'text/event-stream')
|
||||
.timeout(5000); // Add timeout to prevent hanging
|
||||
|
||||
// With valid auth, should NOT return 401 (auth error)
|
||||
// SSE will return 200 and keep connection open
|
||||
expect(response.status).not.toBe(401);
|
||||
}, 10000); // Increase test timeout
|
||||
});
|
||||
|
||||
describe('Global Routes - Bearer Auth Enforcement', () => {
|
||||
it('should reject unauthenticated access to global MCP endpoint when bearer auth enabled', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/mcp/alice-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should accept valid bearer token on global MCP endpoint', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/mcp/alice-private')
|
||||
.set('Authorization', 'Bearer supersecret-value')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
// With valid auth, should NOT return 401 (auth error)
|
||||
expect(response.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Messages Endpoint - Bearer Auth', () => {
|
||||
it('should reject unauthenticated POST to /:user/messages', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/messages?sessionId=fake-session-id')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should accept authenticated POST to /:user/messages', async () => {
|
||||
// Note: This will fail due to missing session, but should pass auth check
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/messages?sessionId=fake-session-id')
|
||||
.set('Authorization', 'Bearer supersecret-value')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
});
|
||||
|
||||
// Should not be 401 (auth error), might be 400 or 404 (session not found)
|
||||
expect(response.status).not.toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Security Considerations', () => {
|
||||
it('should not leak user existence through different error messages', async () => {
|
||||
const existingUserResponse = await request(httpServer)
|
||||
.post('/alice/mcp/alice-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const nonExistingUserResponse = await request(httpServer)
|
||||
.post('/nonexistent/mcp/alice-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
// Both should return same error (401) to avoid user enumeration
|
||||
expect(existingUserResponse.status).toBe(nonExistingUserResponse.status);
|
||||
expect(existingUserResponse.body.error).toBe(nonExistingUserResponse.body.error);
|
||||
});
|
||||
|
||||
it('should include WWW-Authenticate header with proper challenge', async () => {
|
||||
const response = await request(httpServer)
|
||||
.post('/admin/mcp/alice-private')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers['www-authenticate']).toBeDefined();
|
||||
expect(response.headers['www-authenticate']).toMatch(/^Bearer /);
|
||||
expect(response.headers['www-authenticate']).toContain('error="invalid_token"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -141,8 +141,8 @@ describe('Keepalive Functionality', () => {
|
||||
};
|
||||
(mcpService.getMcpServer as jest.Mock).mockReturnValue(mockMcpServer);
|
||||
|
||||
// Mock loadSettings
|
||||
(configModule.loadSettings as jest.Mock).mockReturnValue({
|
||||
// Mock loadSettings and loadOriginalSettings
|
||||
const mockSettingsValue = {
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
@@ -152,7 +152,9 @@ describe('Keepalive Functionality', () => {
|
||||
},
|
||||
},
|
||||
mcpServers: {},
|
||||
});
|
||||
};
|
||||
(configModule.loadSettings as jest.Mock).mockReturnValue(mockSettingsValue);
|
||||
(configModule.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettingsValue);
|
||||
|
||||
// Clear transports
|
||||
Object.keys(transports).forEach((key) => delete transports[key]);
|
||||
|
||||
Reference in New Issue
Block a user