Skip to content

Commit 191dde8

Browse files
LeonmanRollsclaude
andcommitted
fix: prevent deceptive URLs from being clickable in profiles
- Add comprehensive URL validation to detect deceptive patterns - URLs with @ symbols, Unicode spaces, javascript:, data:, file:// protocols are disabled - Deceptive URLs are displayed with strikethrough and are non-clickable - Added tests for URL validation logic - Updated ProfileSnippet, SocialProfileButton, and OtherProfileButton components This prevents attacks where malicious actors could use URLs like: - https://google.com @evil.com (appears as google.com but redirects to evil.com) - URLs with invisible Unicode characters hiding malicious redirects - XSS attempts via javascript: or data: URIs 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ad93ad6 commit 191dde8

File tree

4 files changed

+272
-6
lines changed

4 files changed

+272
-6
lines changed

src/components/ProfileSnippet.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useAbilities } from '@app/hooks/abilities/useAbilities'
1010
import { useBeautifiedName } from '@app/hooks/useBeautifiedName'
1111
import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory'
1212
import { isValidBanner } from '@app/validators/validateBanner'
13+
import { isDeceptiveUrl, getSafeUrl, getUrlDisplayText } from '@app/utils/security/validateUrl'
1314

1415
import { useTransactionFlow } from '../transaction-flow/TransactionFlowProvider'
1516
import { NameAvatar } from './AvatarWithZorb'
@@ -180,7 +181,8 @@ export const getUserDefinedUrl = (url?: string) => {
180181
if (url.startsWith('http://') || url.startsWith('https://')) {
181182
return url
182183
}
183-
return ``
184+
// Don't add protocol to URLs that don't have one
185+
return undefined
184186
}
185187

186188
export const ProfileSnippet = ({
@@ -304,11 +306,23 @@ export const ProfileSnippet = ({
304306
</Typography>
305307
)}
306308
{url && (
307-
<a href={url} data-testid="profile-snippet-url" target="_blank" rel="noreferrer">
308-
<Typography color="blue" id="profile-url">
309-
{url?.replace(/http(s?):\/\//g, '').replace(/\/$/g, '')}
309+
isDeceptiveUrl(url) ? (
310+
<Typography
311+
color="greyPrimary"
312+
id="profile-url"
313+
data-testid="profile-snippet-url-disabled"
314+
style={{ textDecoration: 'line-through', cursor: 'not-allowed' }}
315+
title="This URL contains deceptive patterns and has been disabled for security"
316+
>
317+
{getUrlDisplayText(url)}
310318
</Typography>
311-
</a>
319+
) : (
320+
<a href={url} data-testid="profile-snippet-url" target="_blank" rel="noreferrer">
321+
<Typography color="blue" id="profile-url">
322+
{getUrlDisplayText(url)}
323+
</Typography>
324+
</a>
325+
)
312326
)}
313327
</LocationAndUrl>
314328
)}

src/components/pages/profile/ProfileButton.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { getSocialData } from '@app/utils/getSocialData'
3333
import { makeEtherscanLink, shortenAddress } from '@app/utils/utils'
3434
import { getVerifierData } from '@app/utils/verification/getVerifierData'
3535
import { isVerificationProtocol } from '@app/utils/verification/isVerificationProtocol'
36+
import { isDeceptiveUrl } from '@app/utils/security/validateUrl'
3637

3738
const StyledAddressIcon = styled(DynamicAddressIcon)(
3839
({ theme }) => css`
@@ -88,6 +89,10 @@ export const SocialProfileButton = ({
8889
)
8990

9091
if (!socialData) return null
92+
93+
// Check if the URL is deceptive
94+
const isDeceptive = socialData.type === 'link' && isDeceptiveUrl(socialData.urlFormatter)
95+
9196
return (
9297
<VerificationBadge
9398
isVerified={isVerified}
@@ -101,9 +106,15 @@ export const SocialProfileButton = ({
101106
inline
102107
data-testid={`social-profile-button-${iconKey}`}
103108
value={socialData.value}
104-
{...(socialData.type === 'link'
109+
{...(socialData.type === 'link' && !isDeceptive
105110
? { as: 'a' as const, link: socialData.urlFormatter }
106111
: { as: 'button' as const })}
112+
style={isDeceptive ? {
113+
textDecoration: 'line-through',
114+
opacity: 0.5,
115+
cursor: 'not-allowed'
116+
} : {}}
117+
title={isDeceptive ? 'This URL contains deceptive patterns and has been disabled for security' : undefined}
107118
>
108119
{socialData.value}
109120
</RecordItem>
@@ -230,17 +241,24 @@ export const OtherProfileButton = ({
230241
if (!decodedContentHash) return {}
231242
const _link = getContentHashLink({ name, chainId, decodedContentHash })
232243
if (!_link) return {}
244+
// Check if contenthash link is deceptive
245+
if (isDeceptiveUrl(_link)) return {}
233246
return {
234247
as: 'a',
235248
link: _link,
236249
} as const
237250
}
251+
// Check if regular URL is deceptive
252+
if (isDeceptiveUrl(value)) return {}
238253
return {
239254
as: 'a',
240255
link: value,
241256
} as const
242257
}, [isLink, type, value, name, chainId])
243258

259+
// Determine if the URL is deceptive for styling
260+
const isDeceptive = isLink && !linkProps.link && !linkProps.as
261+
244262
return (
245263
<RecordItem
246264
{...linkProps}
@@ -259,6 +277,12 @@ export const OtherProfileButton = ({
259277
)
260278
}
261279
data-testid={`other-profile-button-${iconKey}`}
280+
style={isDeceptive ? {
281+
textDecoration: 'line-through',
282+
opacity: 0.5,
283+
cursor: 'not-allowed'
284+
} : {}}
285+
title={isDeceptive ? 'This URL contains deceptive patterns and has been disabled for security' : undefined}
262286
>
263287
{formattedValue}
264288
</RecordItem>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { isDeceptiveUrl, getSafeUrl, getUrlDisplayText } from './validateUrl'
3+
4+
describe('isDeceptiveUrl', () => {
5+
it('should return false for valid URLs', () => {
6+
expect(isDeceptiveUrl('https://google.com')).toBe(false)
7+
expect(isDeceptiveUrl('https://app.ens.domains')).toBe(false)
8+
expect(isDeceptiveUrl('http://example.com')).toBe(false)
9+
})
10+
11+
it('should return true for URLs with @ symbol (userinfo)', () => {
12+
expect(isDeceptiveUrl('https://google.com@evil.com')).toBe(true)
13+
expect(isDeceptiveUrl('https://user@evil.com')).toBe(true)
14+
expect(isDeceptiveUrl('https://user:pass@evil.com')).toBe(true)
15+
})
16+
17+
it('should return true for URLs with Unicode spaces', () => {
18+
// EM SPACE
19+
expect(isDeceptiveUrl('https://google.com\u2003@evil.com')).toBe(true)
20+
// ZERO WIDTH SPACE
21+
expect(isDeceptiveUrl('https://google.com\u200b@evil.com')).toBe(true)
22+
// NO-BREAK SPACE
23+
expect(isDeceptiveUrl('https://google.com\u00a0@evil.com')).toBe(true)
24+
})
25+
26+
it('should return true for javascript: and data: URIs', () => {
27+
expect(isDeceptiveUrl('javascript:alert(1)')).toBe(true)
28+
expect(isDeceptiveUrl('Javascript:alert(1)')).toBe(true)
29+
expect(isDeceptiveUrl('data:text/html,<script>alert(1)</script>')).toBe(true)
30+
expect(isDeceptiveUrl('DATA:text/html,<h1>test</h1>')).toBe(true)
31+
})
32+
33+
it('should return true for file:// protocol', () => {
34+
expect(isDeceptiveUrl('file:///etc/passwd')).toBe(true)
35+
expect(isDeceptiveUrl('FILE:///c:/windows/system.ini')).toBe(true)
36+
})
37+
38+
it('should return true for URLs with null bytes', () => {
39+
expect(isDeceptiveUrl('https://example.com\0/evil')).toBe(true)
40+
})
41+
42+
it('should return true for URLs with multiple slashes after protocol', () => {
43+
expect(isDeceptiveUrl('https:///evil.com')).toBe(true)
44+
expect(isDeceptiveUrl('http:////evil.com')).toBe(true)
45+
})
46+
47+
it('should return true for punycode domains', () => {
48+
expect(isDeceptiveUrl('https://xn--e1afmkfd.xn--p1ai')).toBe(true)
49+
expect(isDeceptiveUrl('https://xn--80ak6aa92e.com')).toBe(true)
50+
})
51+
52+
it('should return true for mixed Cyrillic and Latin characters', () => {
53+
// аррӏе.com (Cyrillic 'a' and Latin)
54+
expect(isDeceptiveUrl('https://аррӏе.com')).toBe(true)
55+
})
56+
57+
it('should return true for invalid URLs', () => {
58+
expect(isDeceptiveUrl('not-a-url')).toBe(true)
59+
expect(isDeceptiveUrl('ftp://example.com')).toBe(true)
60+
})
61+
62+
it('should handle undefined and empty strings', () => {
63+
expect(isDeceptiveUrl(undefined)).toBe(false)
64+
expect(isDeceptiveUrl('')).toBe(false)
65+
})
66+
})
67+
68+
describe('getSafeUrl', () => {
69+
it('should return the URL if it is safe', () => {
70+
expect(getSafeUrl('https://google.com')).toBe('https://google.com')
71+
expect(getSafeUrl('http://example.com')).toBe('http://example.com')
72+
})
73+
74+
it('should return undefined for deceptive URLs', () => {
75+
expect(getSafeUrl('https://google.com@evil.com')).toBeUndefined()
76+
expect(getSafeUrl('javascript:alert(1)')).toBeUndefined()
77+
expect(getSafeUrl('file:///etc/passwd')).toBeUndefined()
78+
})
79+
80+
it('should return undefined for URLs without protocol', () => {
81+
expect(getSafeUrl('google.com')).toBeUndefined()
82+
expect(getSafeUrl('www.example.com')).toBeUndefined()
83+
})
84+
85+
it('should handle undefined', () => {
86+
expect(getSafeUrl(undefined)).toBeUndefined()
87+
})
88+
})
89+
90+
describe('getUrlDisplayText', () => {
91+
it('should return hostname and path without protocol', () => {
92+
expect(getUrlDisplayText('https://google.com')).toBe('google.com')
93+
expect(getUrlDisplayText('https://example.com/path')).toBe('example.com/path')
94+
expect(getUrlDisplayText('http://test.com/foo/bar')).toBe('test.com/foo/bar')
95+
})
96+
97+
it('should remove trailing slashes', () => {
98+
expect(getUrlDisplayText('https://google.com/')).toBe('google.com')
99+
expect(getUrlDisplayText('https://example.com/path/')).toBe('example.com/path')
100+
})
101+
102+
it('should handle invalid URLs gracefully', () => {
103+
expect(getUrlDisplayText('not-a-url')).toBe('not-a-url')
104+
expect(getUrlDisplayText('google.com')).toBe('google.com')
105+
})
106+
107+
it('should handle undefined and empty strings', () => {
108+
expect(getUrlDisplayText(undefined)).toBe('')
109+
expect(getUrlDisplayText('')).toBe('')
110+
})
111+
})

src/utils/security/validateUrl.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Validates URLs for security issues and deceptive patterns
3+
* Returns true if URL should be considered invalid/unsafe
4+
*/
5+
export const isDeceptiveUrl = (url: string | undefined): boolean => {
6+
if (!url) return false
7+
8+
try {
9+
// 1. Block userinfo pattern (@ symbol abuse like https://user@evil.com)
10+
if (url.includes('@')) {
11+
return true
12+
}
13+
14+
// 2. Block Unicode spaces and invisible characters that could hide @ or other deceptive patterns
15+
const suspiciousChars = [
16+
'\u2003', // EM SPACE
17+
'\u200B', // ZERO WIDTH SPACE
18+
'\u00A0', // NO-BREAK SPACE
19+
'\u2000', '\u2001', '\u2002', '\u2004', '\u2005', // Various Unicode spaces
20+
'\u2006', '\u2007', '\u2008', '\u2009', '\u200A',
21+
'\uFEFF', // ZERO WIDTH NO-BREAK SPACE
22+
'\u202E', // RIGHT-TO-LEFT OVERRIDE
23+
'\u202D', // LEFT-TO-RIGHT OVERRIDE
24+
]
25+
26+
if (suspiciousChars.some((char) => url.includes(char))) {
27+
return true
28+
}
29+
30+
// 3. Block data: and javascript: URIs (XSS attempts)
31+
const lowerUrl = url.toLowerCase()
32+
if (lowerUrl.startsWith('data:') || lowerUrl.startsWith('javascript:')) {
33+
return true
34+
}
35+
36+
// 4. Block file:// protocol (local file access)
37+
if (lowerUrl.startsWith('file://')) {
38+
return true
39+
}
40+
41+
// 5. Block URLs with null bytes and control characters
42+
if (url.includes('\0') || /[\x00-\x1F\x7F]/.test(url)) {
43+
return true
44+
}
45+
46+
// 6. Block multiple slashes after protocol (parser confusion)
47+
if (url.match(/^https?:\/\/\/+/)) {
48+
return true
49+
}
50+
51+
// Parse URL for additional checks
52+
const parsed = new URL(url)
53+
54+
// 7. Check for punycode domains (potential homograph attacks)
55+
// xn-- prefix indicates punycode encoding
56+
if (parsed.hostname.startsWith('xn--')) {
57+
return true
58+
}
59+
60+
// 8. Optional: Block common URL shorteners (uncomment if desired)
61+
// const urlShorteners = ['bit.ly', 'tinyurl.com', 'goo.gl', 't.co', 'ow.ly', 'short.link']
62+
// if (urlShorteners.includes(parsed.hostname.toLowerCase())) {
63+
// return true
64+
// }
65+
66+
// 9. Check for mixed scripts in domain (e.g., Latin + Cyrillic)
67+
// This helps detect homograph attacks
68+
const hasCyrillic = /[\u0400-\u04FF]/.test(parsed.hostname)
69+
const hasLatin = /[a-zA-Z]/.test(parsed.hostname)
70+
if (hasCyrillic && hasLatin) {
71+
return true
72+
}
73+
74+
return false
75+
} catch {
76+
// If URL parsing fails, consider it invalid
77+
return true
78+
}
79+
}
80+
81+
/**
82+
* Sanitizes URL for safe display
83+
* Returns a safe version of the URL or undefined if invalid
84+
*/
85+
export const getSafeUrl = (url: string | undefined): string | undefined => {
86+
if (!url) return undefined
87+
88+
// If URL is deceptive, return undefined (don't make it clickable)
89+
if (isDeceptiveUrl(url)) {
90+
return undefined
91+
}
92+
93+
// Ensure URL has a protocol
94+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
95+
// Don't add protocol to already invalid URLs
96+
return undefined
97+
}
98+
99+
return url
100+
}
101+
102+
/**
103+
* Get display text for a URL (removes protocol for cleaner display)
104+
*/
105+
export const getUrlDisplayText = (url: string | undefined): string => {
106+
if (!url) return ''
107+
108+
try {
109+
const parsed = new URL(url)
110+
// Return hostname + path without protocol
111+
return `${parsed.hostname}${parsed.pathname === '/' ? '' : parsed.pathname}`
112+
.replace(/\/$/, '') // Remove trailing slash
113+
} catch {
114+
// If parsing fails, return the original URL
115+
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
116+
}
117+
}

0 commit comments

Comments
 (0)