Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions src/components/ProfileSnippet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = ({
Expand Down Expand Up @@ -304,11 +306,23 @@ export const ProfileSnippet = ({
</Typography>
)}
{url && (
<a href={url} data-testid="profile-snippet-url" target="_blank" rel="noreferrer">
<Typography color="blue" id="profile-url">
{url?.replace(/http(s?):\/\//g, '').replace(/\/$/g, '')}
isDeceptiveUrl(url) ? (
<Typography
color="greyPrimary"
id="profile-url"
data-testid="profile-snippet-url-disabled"
style={{ textDecoration: 'line-through', cursor: 'not-allowed' }}
title="This URL contains deceptive patterns and has been disabled for security"
>
{getUrlDisplayText(url)}
</Typography>
</a>
) : (
<a href={url} data-testid="profile-snippet-url" target="_blank" rel="noreferrer">
<Typography color="blue" id="profile-url">
{getUrlDisplayText(url)}
</Typography>
</a>
)
)}
</LocationAndUrl>
)}
Expand Down
26 changes: 25 additions & 1 deletion src/components/pages/profile/ProfileButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 (
<VerificationBadge
isVerified={isVerified}
Expand All @@ -101,9 +106,15 @@ export const SocialProfileButton = ({
inline
data-testid={`social-profile-button-${iconKey}`}
value={socialData.value}
{...(socialData.type === 'link'
{...(socialData.type === 'link' && !isDeceptive
? { as: 'a' as const, link: socialData.urlFormatter }
: { as: 'button' as const })}
style={isDeceptive ? {
textDecoration: 'line-through',
opacity: 0.5,
cursor: 'not-allowed'
} : {}}
title={isDeceptive ? 'This URL contains deceptive patterns and has been disabled for security' : undefined}
>
{socialData.value}
</RecordItem>
Expand Down Expand Up @@ -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 (
<RecordItem
{...linkProps}
Expand All @@ -259,6 +277,12 @@ export const OtherProfileButton = ({
)
}
data-testid={`other-profile-button-${iconKey}`}
style={isDeceptive ? {
textDecoration: 'line-through',
opacity: 0.5,
cursor: 'not-allowed'
} : {}}
title={isDeceptive ? 'This URL contains deceptive patterns and has been disabled for security' : undefined}
>
{formattedValue}
</RecordItem>
Expand Down
111 changes: 111 additions & 0 deletions src/utils/security/validateUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>alert(1)</script>')).toBe(true)
expect(isDeceptiveUrl('DATA:text/html,<h1>test</h1>')).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)

Check failure on line 59 in src/utils/security/validateUrl.test.ts

View workflow job for this annotation

GitHub Actions / coverage

src/utils/security/validateUrl.test.ts > isDeceptiveUrl > should return true for invalid URLs

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/utils/security/validateUrl.test.ts:59:49
})

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('')
})
})
117 changes: 117 additions & 0 deletions src/utils/security/validateUrl.ts
Original file line number Diff line number Diff line change
@@ -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)) {

Check warning on line 42 in src/utils/security/validateUrl.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this control character.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZrASOtlf2WJSxOknjgF&open=AZrASOtlf2WJSxOknjgF&pullRequest=1066

Check warning on line 42 in src/utils/security/validateUrl.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this control character.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZrASOtlf2WJSxOknjgE&open=AZrASOtlf2WJSxOknjgE&pullRequest=1066
return true
}

// 6. Block multiple slashes after protocol (parser confusion)
if (url.match(/^https?:\/\/\/+/)) {

Check warning on line 47 in src/utils/security/validateUrl.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the "RegExp.exec()" method instead.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZrASOtlf2WJSxOknjgG&open=AZrASOtlf2WJSxOknjgG&pullRequest=1066
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(/\/$/, '')
}
}
Loading