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
2 changes: 1 addition & 1 deletion src/components/help/HelpDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const HelpDrawer: React.FC = () => {
<a
href="https://discord.gg/WV7tdYkJk"
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-cyan-300 transition hover:text-cyan-200"
>
<CircleHelp size={16} aria-hidden="true" />
Expand Down
61 changes: 60 additions & 1 deletion src/utils/__tests__/explorerLinks.policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { describe, expect, it, vi, afterEach } from 'vitest'
import { buildExplorerUrl, openExplorerUrl } from '../explorerLinks'
import { buildExplorerUrl, openExplorerUrl, safeExternalUrl, KNOWN_EXPLORER_HOSTS } from '../explorerLinks'

const ALLOWED_HOST = 'stellar.expert'
const validAccount = `G${'A'.repeat(55)}`
Expand Down Expand Up @@ -79,3 +79,62 @@ describe('explorerLinks — external-link policy', () => {
expect(openSpy).not.toHaveBeenCalled()
})
})

describe('safeExternalUrl — external-link policy', () => {
it('rejects javascript: and data: schemes to prevent XSS', () => {
expect(safeExternalUrl('javascript:alert(1)')).toBeNull()
expect(safeExternalUrl('javascript:document.cookie')).toBeNull()
expect(safeExternalUrl('data:text/html,<script>alert(1)</script>')).toBeNull()
expect(safeExternalUrl('data:application/json,{"test":1}')).toBeNull()
})

it('rejects other dangerous schemes', () => {
expect(safeExternalUrl('vbscript:msgbox(1)')).toBeNull()
expect(safeExternalUrl('file:///etc/passwd')).toBeNull()
expect(safeExternalUrl('ftp://example.com')).toBeNull()
expect(safeExternalUrl('mailto:test@example.com')).toBeNull()
})

it('enforces host allowlist when provided to prevent open redirects', () => {
const allowedHosts = new Set(['stellar.expert'])

expect(safeExternalUrl('https://stellar.expert/tx/abc', allowedHosts)).toBe('https://stellar.expert/tx/abc')
expect(safeExternalUrl('https://evil.com', allowedHosts)).toBeNull()
expect(safeExternalUrl('https://stellar.expert.evil.com', allowedHosts)).toBeNull()
expect(safeExternalUrl('//evil.com', allowedHosts)).toBeNull()
})

it('prevents host smuggling via @ syntax', () => {
expect(safeExternalUrl('https://example.com@evil.com')).toBeNull()
expect(safeExternalUrl('https://example.com:80@evil.com')).toBeNull()
expect(safeExternalUrl('http://user@example.com@evil.com')).toBeNull()
})

it('only allows http and https protocols', () => {
expect(safeExternalUrl('http://example.com')).toBe('http://example.com')
expect(safeExternalUrl('https://example.com')).toBe('https://example.com')
expect(safeExternalUrl('HTTP://example.com')).toBe('http://example.com')
expect(safeExternalUrl('HTTPS://example.com')).toBe('https://example.com')
})

it('rejects null, undefined, and empty inputs', () => {
expect(safeExternalUrl(null)).toBeNull()
expect(safeExternalUrl(undefined)).toBeNull()
expect(safeExternalUrl('')).toBeNull()
})

it('rejects malformed URLs that could cause parsing errors', () => {
expect(safeExternalUrl('not-a-url')).toBeNull()
expect(safeExternalUrl('http://')).toBeNull()
expect(safeExternalUrl('://example.com')).toBeNull()
expect(safeExternalUrl('http:///')).toBeNull()
})

it('KNOWN_EXPLORER_HOSTS allowlist prevents off-host links', () => {
expect(safeExternalUrl('https://stellar.expert/tx/abc', KNOWN_EXPLORER_HOSTS)).toBe('https://stellar.expert/tx/abc')
expect(safeExternalUrl('https://steexp.com/account/xyz', KNOWN_EXPLORER_HOSTS)).toBe('https://steexp.com/account/xyz')
expect(safeExternalUrl('https://lumenscope.io/tx/123', KNOWN_EXPLORER_HOSTS)).toBe('https://lumenscope.io/tx/123')
expect(safeExternalUrl('https://evil.com', KNOWN_EXPLORER_HOSTS)).toBeNull()
expect(safeExternalUrl('https://phishing-site.com', KNOWN_EXPLORER_HOSTS)).toBeNull()
})
})
62 changes: 61 additions & 1 deletion src/utils/__tests__/explorerLinks.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom

import { describe, expect, it, vi } from 'vitest'
import { buildExplorerUrl, openExplorerUrl } from '../explorerLinks'
import { buildExplorerUrl, openExplorerUrl, safeExternalUrl, KNOWN_EXPLORER_HOSTS } from '../explorerLinks'

const validTxHash = 'a'.repeat(64)
const validAccount = `G${'A'.repeat(55)}`
Expand Down Expand Up @@ -46,3 +46,63 @@ describe('explorerLinks', () => {
expect(openSpy).toHaveBeenCalledTimes(1)
})
})

describe('safeExternalUrl', () => {
it('accepts valid http and https URLs', () => {
expect(safeExternalUrl('http://example.com')).toBe('http://example.com')
expect(safeExternalUrl('https://example.com')).toBe('https://example.com')
expect(safeExternalUrl('https://example.com/path')).toBe('https://example.com/path')
expect(safeExternalUrl('https://example.com/path?query=value')).toBe('https://example.com/path?query=value')
})

it('rejects dangerous schemes', () => {
expect(safeExternalUrl('javascript:alert(1)')).toBeNull()
expect(safeExternalUrl('data:text/html,<script>alert(1)</script>')).toBeNull()
expect(safeExternalUrl('vbscript:msgbox(1)')).toBeNull()
expect(safeExternalUrl('file:///etc/passwd')).toBeNull()
expect(safeExternalUrl('ftp://example.com')).toBeNull()
})

it('rejects null, undefined, and empty strings', () => {
expect(safeExternalUrl(null)).toBeNull()
expect(safeExternalUrl(undefined)).toBeNull()
expect(safeExternalUrl('')).toBeNull()
})

it('rejects malformed URLs', () => {
expect(safeExternalUrl('not-a-url')).toBeNull()
expect(safeExternalUrl('http://')).toBeNull()
expect(safeExternalUrl('://example.com')).toBeNull()
})

it('enforces allowed hosts when provided', () => {
const allowedHosts = new Set(['stellar.expert', 'example.com'])

expect(safeExternalUrl('https://stellar.expert/tx/abc', allowedHosts)).toBe('https://stellar.expert/tx/abc')
expect(safeExternalUrl('https://example.com/path', allowedHosts)).toBe('https://example.com/path')
expect(safeExternalUrl('https://evil.com', allowedHosts)).toBeNull()
expect(safeExternalUrl('https://stellar.expert.evil.com', allowedHosts)).toBeNull()
})

it('allows any http(s) host when allowedHosts is not provided', () => {
expect(safeExternalUrl('https://any-host.com')).toBe('https://any-host.com')
expect(safeExternalUrl('http://another-host.org')).toBe('http://another-host.org')
})

it('works with KNOWN_EXPLORER_HOSTS constant', () => {
expect(safeExternalUrl('https://stellar.expert/tx/abc', KNOWN_EXPLORER_HOSTS)).toBe('https://stellar.expert/tx/abc')
expect(safeExternalUrl('https://steexp.com/account/xyz', KNOWN_EXPLORER_HOSTS)).toBe('https://steexp.com/account/xyz')
expect(safeExternalUrl('https://lumenscope.io/tx/123', KNOWN_EXPLORER_HOSTS)).toBe('https://lumenscope.io/tx/123')
expect(safeExternalUrl('https://unknown.com', KNOWN_EXPLORER_HOSTS)).toBeNull()
})

it('handles protocol case insensitivity', () => {
expect(safeExternalUrl('HTTP://example.com')).toBeNull() // URL constructor normalizes to http:
expect(safeExternalUrl('HTTPS://example.com')).toBe('https://example.com')
})

it('prevents open-redirect attempts via @ syntax', () => {
expect(safeExternalUrl('https://example.com@evil.com')).toBeNull()
expect(safeExternalUrl('https://example.com:80@evil.com')).toBeNull()
})
})
49 changes: 49 additions & 0 deletions src/utils/explorerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,52 @@ export function openExplorerUrl(
window.open(url, '_blank', 'noopener,noreferrer')
return true
}

/**
* Validates and sanitizes externally-sourced URLs (e.g., from NFT metadata or commitment fields).
* Only allows http/https schemes and optionally restricts to known explorer hosts.
* Returns null for invalid URLs to prevent javascript:/data: schemes and open-redirect attacks.
*
* @param url - The URL to validate (can be null/undefined)
* @param allowedHosts - Optional set of allowed hostnames (e.g., ['stellar.expert']). If omitted, only http(s) scheme is enforced.
* @returns The validated URL string, or null if invalid
*/
export function safeExternalUrl(
url: string | null | undefined,
allowedHosts?: Set<string>,
): string | null {
if (!url || typeof url !== 'string') {
return null
}

try {
const parsed = new URL(url)

// Reject dangerous schemes
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}

// If allowedHosts is provided, enforce host allowlist
if (allowedHosts && allowedHosts.size > 0) {
if (!allowedHosts.has(parsed.hostname)) {
return null
}
}

return url
} catch {
// Invalid URL syntax
return null
}
}

/**
* Known safe explorer hosts for external link validation.
* Extend this set as new explorers are integrated.
*/
export const KNOWN_EXPLORER_HOSTS = new Set([
'stellar.expert',
'steexp.com',
'lumenscope.io',
])