diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 8a4dc62ee..3a6c4d534 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -25,6 +25,33 @@ import { NotificationList } from '@/app/w/[id]/components/notifications/notifica const logger = createLogger('LoginForm') +// Validate callback URL to prevent open redirect vulnerabilities +const validateCallbackUrl = (url: string): boolean => { + try { + // If it's a relative URL, it's safe + if (url.startsWith('/')) { + return true + } + + // If absolute URL, check if it belongs to the same origin + const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' + if (url.startsWith(currentOrigin)) { + return true + } + + // Add other trusted domains if needed + // const trustedDomains = ['trusted-domain.com'] + // if (trustedDomains.some(domain => url.startsWith(`https://${domain}`))) { + // return true + // } + + return false + } catch (error) { + logger.error('Error validating callback URL:', { error, url }) + return false + } +} + export default function LoginPage({ githubAvailable, googleAvailable, @@ -63,7 +90,13 @@ export default function LoginPage({ if (searchParams) { const callback = searchParams.get('callbackUrl') if (callback) { - setCallbackUrl(callback) + // Validate the callbackUrl before setting it + if (validateCallbackUrl(callback)) { + setCallbackUrl(callback) + } else { + logger.warn('Invalid callback URL detected and blocked:', { url: callback }) + // Keep the default safe value ('/w') + } } const inviteFlow = searchParams.get('invite_flow') === 'true' @@ -79,12 +112,14 @@ export default function LoginPage({ const email = formData.get('email') as string try { - // Use the extracted callbackUrl instead of hardcoded value + // Final validation before submission + const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/w' + const result = await client.signIn.email( { email, password, - callbackURL: callbackUrl, + callbackURL: safeCallbackUrl, }, { onError: (ctx) => { diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index 2a6df10e7..5f9d45a66 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -50,6 +50,37 @@ const SyncPayloadSchema = z.object({ // Cache for workspace membership to reduce DB queries const workspaceMembershipCache = new Map(); const CACHE_TTL = 60000; // 1 minute cache expiration +const MAX_CACHE_SIZE = 1000; // Maximum number of entries to prevent unbounded growth + +/** + * Cleans up expired entries from the workspace membership cache + */ +function cleanupExpiredCacheEntries(): void { + const now = Date.now(); + let expiredCount = 0; + + // Remove expired entries + for (const [key, value] of workspaceMembershipCache.entries()) { + if (value.expires <= now) { + workspaceMembershipCache.delete(key); + expiredCount++; + } + } + + // If we're still over the limit after removing expired entries, + // remove the oldest entries (those that will expire soonest) + if (workspaceMembershipCache.size > MAX_CACHE_SIZE) { + const entries = Array.from(workspaceMembershipCache.entries()) + .sort((a, b) => a[1].expires - b[1].expires); + + const toRemove = entries.slice(0, workspaceMembershipCache.size - MAX_CACHE_SIZE); + toRemove.forEach(([key]) => workspaceMembershipCache.delete(key)); + + logger.debug(`Cache cleanup: removed ${expiredCount} expired entries and ${toRemove.length} additional entries due to size limit`); + } else if (expiredCount > 0) { + logger.debug(`Cache cleanup: removed ${expiredCount} expired entries`); + } +} /** * Efficiently verifies user's membership and role in a workspace with caching @@ -58,6 +89,11 @@ const CACHE_TTL = 60000; // 1 minute cache expiration * @returns Role if user is a member, null otherwise */ async function verifyWorkspaceMembership(userId: string, workspaceId: string): Promise { + // Opportunistic cleanup of expired cache entries + if (workspaceMembershipCache.size > MAX_CACHE_SIZE / 2) { + cleanupExpiredCacheEntries(); + } + // Create cache key from userId and workspaceId const cacheKey = `${userId}:${workspaceId}`; diff --git a/apps/sim/app/api/workspaces/invitations/accept/route.ts b/apps/sim/app/api/workspaces/invitations/accept/route.ts index 8f25a0d0e..4205960a8 100644 --- a/apps/sim/app/api/workspaces/invitations/accept/route.ts +++ b/apps/sim/app/api/workspaces/invitations/accept/route.ts @@ -48,13 +48,26 @@ export async function GET(req: NextRequest) { const userEmail = session.user.email.toLowerCase() const invitationEmail = invitation.email.toLowerCase() - // Check if invitation email matches the logged-in user - // For new users who just signed up, we'll be more flexible by comparing domain parts + // Check if the logged-in user's email matches the invitation + // We'll use exact matching as the primary check const isExactMatch = userEmail === invitationEmail - const isPartialMatch = userEmail.split('@')[1] === invitationEmail.split('@')[1] && - userEmail.split('@')[0].includes(invitationEmail.split('@')[0].substring(0, 3)) - if (!isExactMatch && !isPartialMatch) { + // For SSO or company email variants, check domain and normalized username + // This handles cases like john.doe@company.com vs john@company.com + const normalizeUsername = (email: string): string => { + return email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '').toLowerCase() + } + + const isSameDomain = userEmail.split('@')[1] === invitationEmail.split('@')[1] + const normalizedUserEmail = normalizeUsername(userEmail) + const normalizedInvitationEmail = normalizeUsername(invitationEmail) + const isSimilarUsername = normalizedUserEmail === normalizedInvitationEmail || + (normalizedUserEmail.includes(normalizedInvitationEmail) || + normalizedInvitationEmail.includes(normalizedUserEmail)) + + const isValidMatch = isExactMatch || (isSameDomain && isSimilarUsername) + + if (!isValidMatch) { // Get user info to include in the error message const userData = await db .select() diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index 222234a46..b72d898ff 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -146,8 +146,6 @@ export default function Invite() { invitationId: inviteId, }) - console.log('Invitation acceptance response:', response) - // Set the active organization to the one just joined const orgId = response.data?.invitation.organizationId || invitationDetails?.data?.organizationId