mirror of
https://github.com/samanhappy/mcphub.git
synced 2026-01-01 04:08:52 -05:00
fix: Address code review feedback for OAuth SSO
- Add proper lifecycle management for state cleanup interval - Fix host header injection vulnerability by validating forwarded headers - Add type safety for GitHub API responses - Add stopStateCleanup function for test cleanup - Document scaling limitations of in-memory state store Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
This commit is contained in:
@@ -12,12 +12,40 @@ import {
|
|||||||
getPublicProviderInfo,
|
getPublicProviderInfo,
|
||||||
isLocalAuthAllowed,
|
isLocalAuthAllowed,
|
||||||
isOAuthSsoEnabled,
|
isOAuthSsoEnabled,
|
||||||
|
getOAuthSsoConfig as getSsoConfigFromService,
|
||||||
} from '../services/oauthSsoService.js';
|
} from '../services/oauthSsoService.js';
|
||||||
import { JWT_SECRET } from '../config/jwt.js';
|
import { JWT_SECRET } from '../config/jwt.js';
|
||||||
import config from '../config/index.js';
|
import config from '../config/index.js';
|
||||||
|
|
||||||
const TOKEN_EXPIRY = '24h';
|
const TOKEN_EXPIRY = '24h';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for OAuth callbacks
|
||||||
|
* Uses configured callbackBaseUrl if available, otherwise derives from request
|
||||||
|
* This approach is more secure than blindly trusting forwarded headers
|
||||||
|
*/
|
||||||
|
async function getCallbackBaseUrl(req: Request): Promise<string> {
|
||||||
|
// First, check if a callback base URL is configured (most secure option)
|
||||||
|
const ssoConfig = await getSsoConfigFromService();
|
||||||
|
if (ssoConfig?.callbackBaseUrl) {
|
||||||
|
return ssoConfig.callbackBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to deriving from request (less secure, but works in simpler setups)
|
||||||
|
// Only trust forwarded headers if app is configured to trust proxy
|
||||||
|
if (req.app.get('trust proxy') && req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']) {
|
||||||
|
const proto = Array.isArray(req.headers['x-forwarded-proto'])
|
||||||
|
? req.headers['x-forwarded-proto'][0]
|
||||||
|
: req.headers['x-forwarded-proto'];
|
||||||
|
const host = Array.isArray(req.headers['x-forwarded-host'])
|
||||||
|
? req.headers['x-forwarded-host'][0]
|
||||||
|
: req.headers['x-forwarded-host'];
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${req.protocol}://${req.get('host')}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get OAuth SSO configuration for frontend
|
* Get OAuth SSO configuration for frontend
|
||||||
* Returns enabled providers and whether local auth is allowed
|
* Returns enabled providers and whether local auth is allowed
|
||||||
@@ -65,10 +93,9 @@ export const initiateOAuthLogin = async (req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build callback URL
|
// Build callback URL
|
||||||
const baseUrl =
|
// Note: Use configured callback base URL from oauthSso config if available
|
||||||
req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']
|
// This avoids relying on potentially untrusted forwarded headers
|
||||||
? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}`
|
const baseUrl = await getCallbackBaseUrl(req);
|
||||||
: `${req.protocol}://${req.get('host')}`;
|
|
||||||
|
|
||||||
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
|
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
|
||||||
|
|
||||||
@@ -121,10 +148,7 @@ export const handleOAuthCallback = async (req: Request, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build callback URL (same as used in initiate)
|
// Build callback URL (same as used in initiate)
|
||||||
const baseUrl =
|
const baseUrl = await getCallbackBaseUrl(req);
|
||||||
req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']
|
|
||||||
? `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}`
|
|
||||||
: `${req.protocol}://${req.get('host')}`;
|
|
||||||
|
|
||||||
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
|
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { getSystemConfigDao, getUserDao } from '../dao/index.js';
|
|||||||
import { IUser, OAuthSsoProviderConfig, OAuthSsoConfig } from '../types/index.js';
|
import { IUser, OAuthSsoProviderConfig, OAuthSsoConfig } from '../types/index.js';
|
||||||
|
|
||||||
// In-memory store for OAuth state (code verifier, state, etc.)
|
// In-memory store for OAuth state (code verifier, state, etc.)
|
||||||
// In production, consider using Redis or database for multi-instance deployments
|
// NOTE: This implementation uses in-memory storage which is suitable for single-instance deployments.
|
||||||
|
// For multi-instance/scaled deployments, implement Redis or database-backed state storage
|
||||||
|
// to ensure OAuth callbacks reach the correct instance where the state was stored.
|
||||||
interface OAuthStateEntry {
|
interface OAuthStateEntry {
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
@@ -23,7 +25,11 @@ const stateStore = new Map<string, OAuthStateEntry>();
|
|||||||
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
// Cleanup old state entries periodically
|
// Cleanup old state entries periodically
|
||||||
setInterval(() => {
|
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startStateCleanup(): void {
|
||||||
|
if (cleanupInterval) return;
|
||||||
|
cleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [state, entry] of stateStore.entries()) {
|
for (const [state, entry] of stateStore.entries()) {
|
||||||
if (now - entry.createdAt > STATE_TTL_MS) {
|
if (now - entry.createdAt > STATE_TTL_MS) {
|
||||||
@@ -31,6 +37,36 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60 * 1000); // Cleanup every minute
|
}, 60 * 1000); // Cleanup every minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleanup on module load
|
||||||
|
startStateCleanup();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the state cleanup interval (useful for tests and graceful shutdown)
|
||||||
|
*/
|
||||||
|
export function stopStateCleanup(): void {
|
||||||
|
if (cleanupInterval) {
|
||||||
|
clearInterval(cleanupInterval);
|
||||||
|
cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHub API response types for type safety
|
||||||
|
interface GitHubUserResponse {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubEmailResponse {
|
||||||
|
email: string;
|
||||||
|
primary: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
visibility?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Provider configurations cache
|
// Provider configurations cache
|
||||||
const providerConfigsCache = new Map<
|
const providerConfigsCache = new Map<
|
||||||
@@ -326,7 +362,7 @@ async function getUserInfo(
|
|||||||
throw new Error(`Failed to fetch GitHub user info: ${response.statusText}`);
|
throw new Error(`Failed to fetch GitHub user info: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = (await response.json()) as GitHubUserResponse;
|
||||||
|
|
||||||
// Fetch email separately if not public
|
// Fetch email separately if not public
|
||||||
let email = data.email;
|
let email = data.email;
|
||||||
@@ -339,8 +375,8 @@ async function getUserInfo(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (emailResponse.ok) {
|
if (emailResponse.ok) {
|
||||||
const emails = await emailResponse.json();
|
const emails = (await emailResponse.json()) as GitHubEmailResponse[];
|
||||||
const primaryEmail = emails.find((e: any) => e.primary);
|
const primaryEmail = emails.find((e) => e.primary);
|
||||||
email = primaryEmail?.email || emails[0]?.email;
|
email = primaryEmail?.email || emails[0]?.email;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
isLocalAuthAllowed,
|
isLocalAuthAllowed,
|
||||||
getPublicProviderInfo,
|
getPublicProviderInfo,
|
||||||
clearProviderCache,
|
clearProviderCache,
|
||||||
|
stopStateCleanup,
|
||||||
} from '../../src/services/oauthSsoService.js';
|
} from '../../src/services/oauthSsoService.js';
|
||||||
|
|
||||||
describe('OAuth SSO Service', () => {
|
describe('OAuth SSO Service', () => {
|
||||||
@@ -32,6 +33,11 @@ describe('OAuth SSO Service', () => {
|
|||||||
>;
|
>;
|
||||||
const mockGetUserDao = daoModule.getUserDao as jest.MockedFunction<typeof daoModule.getUserDao>;
|
const mockGetUserDao = daoModule.getUserDao as jest.MockedFunction<typeof daoModule.getUserDao>;
|
||||||
|
|
||||||
|
// Stop the cleanup interval to prevent Jest from hanging
|
||||||
|
afterAll(() => {
|
||||||
|
stopStateCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
const defaultSsoConfig = {
|
const defaultSsoConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowLocalAuth: true,
|
allowLocalAuth: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user