diff --git a/src/components/ProfileSnippet.tsx b/src/components/ProfileSnippet.tsx index f5b6cbaca..6310949cd 100644 --- a/src/components/ProfileSnippet.tsx +++ b/src/components/ProfileSnippet.tsx @@ -10,6 +10,7 @@ import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useBeautifiedName } from '@app/hooks/useBeautifiedName' import { useEnsAvatar } from '@app/hooks/useEnsAvatar' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' +import { isDeceptiveUrl, getUrlDisplayText } from '@app/utils/security/validateUrl' import { useTransactionFlow } from '../transaction-flow/TransactionFlowProvider' import { NameAvatar } from './AvatarWithZorb' @@ -180,7 +181,8 @@ export const getUserDefinedUrl = (url?: string) => { if (url.startsWith('http://') || url.startsWith('https://')) { return url } - return `` + // Don't add protocol to URLs that don't have one + return undefined } export const ProfileSnippet = ({ @@ -304,11 +306,23 @@ export const ProfileSnippet = ({ )} {url && ( - - - {url?.replace(/http(s?):\/\//g, '').replace(/\/$/g, '')} + isDeceptiveUrl(url) ? ( + + {getUrlDisplayText(url)} - + ) : ( + + + {getUrlDisplayText(url)} + + + ) )} )} diff --git a/src/components/pages/profile/ProfileButton.tsx b/src/components/pages/profile/ProfileButton.tsx index f5af3df33..37cdc758c 100644 --- a/src/components/pages/profile/ProfileButton.tsx +++ b/src/components/pages/profile/ProfileButton.tsx @@ -33,6 +33,7 @@ import { getSocialData } from '@app/utils/getSocialData' import { makeEtherscanLink, shortenAddress } from '@app/utils/utils' import { getVerifierData } from '@app/utils/verification/getVerifierData' import { isVerificationProtocol } from '@app/utils/verification/isVerificationProtocol' +import { isDeceptiveUrl } from '@app/utils/security/validateUrl' const StyledAddressIcon = styled(DynamicAddressIcon)( ({ theme }) => css` @@ -88,6 +89,10 @@ export const SocialProfileButton = ({ ) if (!socialData) return null + + // Check if the URL is deceptive + const isDeceptive = socialData.type === 'link' && isDeceptiveUrl(socialData.urlFormatter) + return ( {socialData.value} @@ -230,17 +241,24 @@ export const OtherProfileButton = ({ if (!decodedContentHash) return {} const _link = getContentHashLink({ name, chainId, decodedContentHash }) if (!_link) return {} + // Check if contenthash link is deceptive + if (isDeceptiveUrl(_link)) return {} return { as: 'a', link: _link, } as const } + // Check if regular URL is deceptive + if (isDeceptiveUrl(value)) return {} return { as: 'a', link: value, } as const }, [isLink, type, value, name, chainId]) + // Determine if the URL is deceptive for styling + const isDeceptive = isLink && !linkProps.link && !linkProps.as + return ( {formattedValue} diff --git a/src/utils/security/validateUrl.test.ts b/src/utils/security/validateUrl.test.ts new file mode 100644 index 000000000..7757c062f --- /dev/null +++ b/src/utils/security/validateUrl.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { isDeceptiveUrl, getSafeUrl, getUrlDisplayText } from './validateUrl' + +describe('isDeceptiveUrl', () => { + it('should return false for valid URLs', () => { + expect(isDeceptiveUrl('https://google.com')).toBe(false) + expect(isDeceptiveUrl('https://app.ens.domains')).toBe(false) + expect(isDeceptiveUrl('http://example.com')).toBe(false) + }) + + it('should return true for URLs with @ symbol (userinfo)', () => { + expect(isDeceptiveUrl('https://google.com@evil.com')).toBe(true) + expect(isDeceptiveUrl('https://user@evil.com')).toBe(true) + expect(isDeceptiveUrl('https://user:pass@evil.com')).toBe(true) + }) + + it('should return true for URLs with Unicode spaces', () => { + // EM SPACE + expect(isDeceptiveUrl('https://google.com\u2003@evil.com')).toBe(true) + // ZERO WIDTH SPACE + expect(isDeceptiveUrl('https://google.com\u200b@evil.com')).toBe(true) + // NO-BREAK SPACE + expect(isDeceptiveUrl('https://google.com\u00a0@evil.com')).toBe(true) + }) + + it('should return true for javascript: and data: URIs', () => { + expect(isDeceptiveUrl('javascript:alert(1)')).toBe(true) + expect(isDeceptiveUrl('Javascript:alert(1)')).toBe(true) + expect(isDeceptiveUrl('data:text/html,')).toBe(true) + expect(isDeceptiveUrl('DATA:text/html,

test

')).toBe(true) + }) + + it('should return true for file:// protocol', () => { + expect(isDeceptiveUrl('file:///etc/passwd')).toBe(true) + expect(isDeceptiveUrl('FILE:///c:/windows/system.ini')).toBe(true) + }) + + it('should return true for URLs with null bytes', () => { + expect(isDeceptiveUrl('https://example.com\0/evil')).toBe(true) + }) + + it('should return true for URLs with multiple slashes after protocol', () => { + expect(isDeceptiveUrl('https:///evil.com')).toBe(true) + expect(isDeceptiveUrl('http:////evil.com')).toBe(true) + }) + + it('should return true for punycode domains', () => { + expect(isDeceptiveUrl('https://xn--e1afmkfd.xn--p1ai')).toBe(true) + expect(isDeceptiveUrl('https://xn--80ak6aa92e.com')).toBe(true) + }) + + it('should return true for mixed Cyrillic and Latin characters', () => { + // аррӏе.com (Cyrillic 'a' and Latin) + expect(isDeceptiveUrl('https://аррӏе.com')).toBe(true) + }) + + it('should return true for invalid URLs', () => { + expect(isDeceptiveUrl('not-a-url')).toBe(true) + expect(isDeceptiveUrl('ftp://example.com')).toBe(true) + }) + + it('should handle undefined and empty strings', () => { + expect(isDeceptiveUrl(undefined)).toBe(false) + expect(isDeceptiveUrl('')).toBe(false) + }) +}) + +describe('getSafeUrl', () => { + it('should return the URL if it is safe', () => { + expect(getSafeUrl('https://google.com')).toBe('https://google.com') + expect(getSafeUrl('http://example.com')).toBe('http://example.com') + }) + + it('should return undefined for deceptive URLs', () => { + expect(getSafeUrl('https://google.com@evil.com')).toBeUndefined() + expect(getSafeUrl('javascript:alert(1)')).toBeUndefined() + expect(getSafeUrl('file:///etc/passwd')).toBeUndefined() + }) + + it('should return undefined for URLs without protocol', () => { + expect(getSafeUrl('google.com')).toBeUndefined() + expect(getSafeUrl('www.example.com')).toBeUndefined() + }) + + it('should handle undefined', () => { + expect(getSafeUrl(undefined)).toBeUndefined() + }) +}) + +describe('getUrlDisplayText', () => { + it('should return hostname and path without protocol', () => { + expect(getUrlDisplayText('https://google.com')).toBe('google.com') + expect(getUrlDisplayText('https://example.com/path')).toBe('example.com/path') + expect(getUrlDisplayText('http://test.com/foo/bar')).toBe('test.com/foo/bar') + }) + + it('should remove trailing slashes', () => { + expect(getUrlDisplayText('https://google.com/')).toBe('google.com') + expect(getUrlDisplayText('https://example.com/path/')).toBe('example.com/path') + }) + + it('should handle invalid URLs gracefully', () => { + expect(getUrlDisplayText('not-a-url')).toBe('not-a-url') + expect(getUrlDisplayText('google.com')).toBe('google.com') + }) + + it('should handle undefined and empty strings', () => { + expect(getUrlDisplayText(undefined)).toBe('') + expect(getUrlDisplayText('')).toBe('') + }) +}) \ No newline at end of file diff --git a/src/utils/security/validateUrl.ts b/src/utils/security/validateUrl.ts new file mode 100644 index 000000000..385b555f3 --- /dev/null +++ b/src/utils/security/validateUrl.ts @@ -0,0 +1,117 @@ +/** + * Validates URLs for security issues and deceptive patterns + * Returns true if URL should be considered invalid/unsafe + */ +export const isDeceptiveUrl = (url: string | undefined): boolean => { + if (!url) return false + + try { + // 1. Block userinfo pattern (@ symbol abuse like https://user@evil.com) + if (url.includes('@')) { + return true + } + + // 2. Block Unicode spaces and invisible characters that could hide @ or other deceptive patterns + const suspiciousChars = [ + '\u2003', // EM SPACE + '\u200B', // ZERO WIDTH SPACE + '\u00A0', // NO-BREAK SPACE + '\u2000', '\u2001', '\u2002', '\u2004', '\u2005', // Various Unicode spaces + '\u2006', '\u2007', '\u2008', '\u2009', '\u200A', + '\uFEFF', // ZERO WIDTH NO-BREAK SPACE + '\u202E', // RIGHT-TO-LEFT OVERRIDE + '\u202D', // LEFT-TO-RIGHT OVERRIDE + ] + + if (suspiciousChars.some((char) => url.includes(char))) { + return true + } + + // 3. Block data: and javascript: URIs (XSS attempts) + const lowerUrl = url.toLowerCase() + if (lowerUrl.startsWith('data:') || lowerUrl.startsWith('javascript:')) { + return true + } + + // 4. Block file:// protocol (local file access) + if (lowerUrl.startsWith('file://')) { + return true + } + + // 5. Block URLs with null bytes and control characters + if (url.includes('\0') || /[\x00-\x1F\x7F]/.test(url)) { + return true + } + + // 6. Block multiple slashes after protocol (parser confusion) + if (url.match(/^https?:\/\/\/+/)) { + return true + } + + // Parse URL for additional checks + const parsed = new URL(url) + + // 7. Check for punycode domains (potential homograph attacks) + // xn-- prefix indicates punycode encoding + if (parsed.hostname.startsWith('xn--')) { + return true + } + + // 8. Optional: Block common URL shorteners (uncomment if desired) + // const urlShorteners = ['bit.ly', 'tinyurl.com', 'goo.gl', 't.co', 'ow.ly', 'short.link'] + // if (urlShorteners.includes(parsed.hostname.toLowerCase())) { + // return true + // } + + // 9. Check for mixed scripts in domain (e.g., Latin + Cyrillic) + // This helps detect homograph attacks + const hasCyrillic = /[\u0400-\u04FF]/.test(parsed.hostname) + const hasLatin = /[a-zA-Z]/.test(parsed.hostname) + if (hasCyrillic && hasLatin) { + return true + } + + return false + } catch { + // If URL parsing fails, consider it invalid + return true + } +} + +/** + * Sanitizes URL for safe display + * Returns a safe version of the URL or undefined if invalid + */ +export const getSafeUrl = (url: string | undefined): string | undefined => { + if (!url) return undefined + + // If URL is deceptive, return undefined (don't make it clickable) + if (isDeceptiveUrl(url)) { + return undefined + } + + // Ensure URL has a protocol + if (!url.startsWith('http://') && !url.startsWith('https://')) { + // Don't add protocol to already invalid URLs + return undefined + } + + return url +} + +/** + * Get display text for a URL (removes protocol for cleaner display) + */ +export const getUrlDisplayText = (url: string | undefined): string => { + if (!url) return '' + + try { + const parsed = new URL(url) + // Return hostname + path without protocol + return `${parsed.hostname}${parsed.pathname === '/' ? '' : parsed.pathname}` + .replace(/\/$/, '') // Remove trailing slash + } catch { + // If parsing fails, return the original URL + return url.replace(/^https?:\/\//, '').replace(/\/$/, '') + } +} \ No newline at end of file