mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
fix(invite-workspace): addressed comments
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -50,6 +50,37 @@ const SyncPayloadSchema = z.object({
|
||||
// Cache for workspace membership to reduce DB queries
|
||||
const workspaceMembershipCache = new Map<string, { role: string, expires: number }>();
|
||||
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<string | null> {
|
||||
// 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}`;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user