diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index f6e842a602f..9dd5cc78dbc 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -16,6 +16,7 @@ import { } from '@/components/emcn' import { client } from '@/lib/auth/auth-client' import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' +import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -53,24 +54,6 @@ const PASSWORD_VALIDATIONS = { }, } -const validateCallbackUrl = (url: string): boolean => { - try { - if (url.startsWith('/')) { - return true - } - - const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' - if (url.startsWith(currentOrigin)) { - return true - } - - return false - } catch (error) { - logger.error('Error validating callback URL:', { error, url }) - return false - } -} - const validatePassword = (passwordValue: string): string[] => { const errors: string[] = [] @@ -106,13 +89,13 @@ export default function LoginPage({ const buttonClass = useBrandedButtonClass() const callbackUrlParam = searchParams?.get('callbackUrl') + const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false const invalidCallbackRef = useRef(false) - if (callbackUrlParam && !validateCallbackUrl(callbackUrlParam) && !invalidCallbackRef.current) { + if (callbackUrlParam && !isValidCallbackUrl && !invalidCallbackRef.current) { invalidCallbackRef.current = true logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam }) } - const callbackUrl = - callbackUrlParam && validateCallbackUrl(callbackUrlParam) ? callbackUrlParam : '/workspace' + const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace' const isInviteFlow = searchParams?.get('invite_flow') === 'true' const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) @@ -192,7 +175,7 @@ export default function LoginPage({ } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' + const safeCallbackUrl = callbackUrl let errorHandled = false const result = await client.signIn.email( diff --git a/apps/sim/app/(home)/components/enterprise/enterprise.tsx b/apps/sim/app/(home)/components/enterprise/enterprise.tsx index d4646a3074f..17c36b9b17c 100644 --- a/apps/sim/app/(home)/components/enterprise/enterprise.tsx +++ b/apps/sim/app/(home)/components/enterprise/enterprise.tsx @@ -35,25 +35,6 @@ const ACTOR_COLORS: Record = { /** Left accent bar opacity by recency — newest is brightest. */ const ACCENT_OPACITIES = [0.75, 0.5, 0.35, 0.22, 0.12, 0.05] as const -/** Human-readable label per resource type. */ -const RESOURCE_TYPE_LABEL: Record = { - workflow: 'Workflow', - member: 'Member', - byok_key: 'BYOK Key', - api_key: 'API Key', - permission_group: 'Permission Group', - credential_set: 'Credential Set', - knowledge_base: 'Knowledge Base', - environment: 'Environment', - mcp_server: 'MCP Server', - file: 'File', - webhook: 'Webhook', - chat: 'Chat', - table: 'Table', - folder: 'Folder', - document: 'Document', -} - interface LogEntry { id: number actor: string @@ -189,7 +170,6 @@ function AuditRow({ entry, index }: AuditRowProps) { const color = ACTOR_COLORS[entry.actor] ?? '#F6F6F6' const accentOpacity = ACCENT_OPACITIES[index] ?? 0.04 const timeAgo = formatTimeAgo(entry.insertedAt) - const resourceLabel = RESOURCE_TYPE_LABEL[entry.resourceType] return (
@@ -292,9 +272,6 @@ function AuditLogPreview() { ) } -const CHECK_PATH = - 'M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z' - interface PermissionFeature { name: string key: string @@ -377,8 +354,6 @@ function AccessControlPanel() { const isInView = useInView(ref, { once: true, margin: '-40px' }) const [accessState, setAccessState] = useState>(INITIAL_ACCESS_STATE) - const allFeatures = PERMISSION_CATEGORIES.flatMap((c) => c.features) - return (
diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 22f8aff6c79..8e8ee3e0be3 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import { Button, Input, Label } from '@/components/emcn' import { client } from '@/lib/auth/auth-client' import { env, isFalsy } from '@/lib/core/config/env' +import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { cn } from '@/lib/core/utils/cn' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { BrandedButton } from '@/app/(auth)/components/branded-button' @@ -29,24 +30,6 @@ const validateEmailField = (emailValue: string): string[] => { return errors } -const validateCallbackUrl = (url: string): boolean => { - try { - if (url.startsWith('/')) { - return true - } - - const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' - if (url.startsWith(currentOrigin)) { - return true - } - - return false - } catch (error) { - logger.error('Error validating callback URL:', { error, url }) - return false - } -} - export default function SSOForm() { const router = useRouter() const searchParams = useSearchParams() @@ -115,7 +98,7 @@ export default function SSOForm() { } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' + const safeCallbackUrl = callbackUrl await client.signIn.sso({ email: emailValue, diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index e4dc671196d..21abb77c545 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1169,3 +1169,26 @@ export function validatePaginationCursor( return { isValid: true, sanitized: value } } + +/** + * Validates a callback URL to prevent open redirect attacks. + * Accepts relative paths and absolute URLs matching the current origin. + * + * @param url - The callback URL to validate + * @returns true if the URL is safe to redirect to + */ +export function validateCallbackUrl(url: string): boolean { + try { + if (url.startsWith('/')) return true + + if (typeof window === 'undefined') return false + + const currentOrigin = window.location.origin + if (url.startsWith(currentOrigin)) return true + + return false + } catch (error) { + logger.error('Error validating callback URL:', { error, url }) + return false + } +} diff --git a/apps/sim/lib/messaging/email/validation.ts b/apps/sim/lib/messaging/email/validation.ts index 821dc5a9f1b..4e076a30e19 100644 --- a/apps/sim/lib/messaging/email/validation.ts +++ b/apps/sim/lib/messaging/email/validation.ts @@ -64,6 +64,14 @@ const DISPOSABLE_MX_BACKENDS = new Set(['in.mail.gw', 'smtp.catchmail.io', 'mx.y /** Per-domain MX result cache — avoids redundant DNS queries for concurrent or repeated sign-ups */ const mxCache = new Map() +const MX_CACHE_MAX = 1_000 + +function setMxCache(domain: string, entry: { result: boolean; expires: number }) { + if (mxCache.size >= MX_CACHE_MAX && !mxCache.has(domain)) { + mxCache.delete(mxCache.keys().next().value!) + } + mxCache.set(domain, entry) +} /** * Validates email syntax using RFC 5322 compliant regex @@ -124,7 +132,10 @@ export async function isDisposableMxBackend(email: string): Promise { const now = Date.now() const cached = mxCache.get(domain) - if (cached && cached.expires > now) return cached.result + if (cached) { + if (cached.expires > now) return cached.result + mxCache.delete(domain) + } let timeoutId: ReturnType | undefined try { @@ -135,10 +146,10 @@ export async function isDisposableMxBackend(email: string): Promise { } ) const result = await Promise.race([mxCheckPromise, timeoutPromise]) - mxCache.set(domain, { result: result.isDisposableBackend, expires: now + 5 * 60 * 1000 }) + setMxCache(domain, { result: result.isDisposableBackend, expires: now + 5 * 60 * 1000 }) return result.isDisposableBackend } catch { - mxCache.set(domain, { result: false, expires: now + 60 * 1000 }) + setMxCache(domain, { result: false, expires: now + 60 * 1000 }) return false } finally { clearTimeout(timeoutId)