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