fix(invite-workspace): addressed comments

This commit is contained in:
Emir Karabeg
2025-05-14 18:46:49 -07:00
parent 43cb4cbf15
commit 274ebdf4eb
4 changed files with 92 additions and 10 deletions

View File

@@ -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) => {

View File

@@ -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}`;

View File

@@ -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()

View File

@@ -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