From b1c6ed503e23c2cf0a5c1ac1adcea741b72d808b Mon Sep 17 00:00:00 2001 From: Raul <311655720+raul-clearframe@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:41:27 -0600 Subject: [PATCH] fix(mobile): harden address-bar navigation --- apps/mobile/src/lib/navigation.test.ts | 41 +++++++++++++++++++++++ apps/mobile/src/lib/navigation.ts | 41 +++++++++++++++++++++++ apps/mobile/src/screens/BrowserScreen.tsx | 12 +------ 3 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/src/lib/navigation.test.ts create mode 100644 apps/mobile/src/lib/navigation.ts diff --git a/apps/mobile/src/lib/navigation.test.ts b/apps/mobile/src/lib/navigation.test.ts new file mode 100644 index 0000000..d502601 --- /dev/null +++ b/apps/mobile/src/lib/navigation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { HOME, normalizeUrl } from './navigation'; + +describe('normalizeUrl', () => { + it('returns home for blank input', () => { + expect(normalizeUrl(' ')).toBe(HOME); + }); + + it('keeps valid HTTP(S) URLs', () => { + expect(normalizeUrl('https://example.com/path?q=1')).toBe( + 'https://example.com/path?q=1', + ); + expect(normalizeUrl('http://localhost:3000/health')).toBe( + 'http://localhost:3000/health', + ); + }); + + it('promotes a complete domain to HTTPS', () => { + expect(normalizeUrl('docs.example.com/path')).toBe( + 'https://docs.example.com/path', + ); + }); + + it('searches ordinary text', () => { + expect(normalizeUrl('privacy first browser')).toBe( + 'https://duckduckgo.com/?q=privacy%20first%20browser', + ); + }); + + it('searches unsupported schemes instead of loading them', () => { + expect(normalizeUrl('javascript:alert(1)')).toBe( + 'https://duckduckgo.com/?q=javascript%3Aalert(1)', + ); + }); + + it('does not treat domain-looking text with spaces as a URL', () => { + expect(normalizeUrl('example.com malicious suffix')).toBe( + 'https://duckduckgo.com/?q=example.com%20malicious%20suffix', + ); + }); +}); diff --git a/apps/mobile/src/lib/navigation.ts b/apps/mobile/src/lib/navigation.ts new file mode 100644 index 0000000..e746c76 --- /dev/null +++ b/apps/mobile/src/lib/navigation.ts @@ -0,0 +1,41 @@ +export const HOME = 'https://tronbrowser.dev'; + +const DOMAIN_OR_IP = + /^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|(?:\d{1,3}\.){3}\d{1,3}|localhost)(?::\d{1,5})?(?:[/?#][^\s]*)?$/i; + +function searchUrl(query: string): string { + return `https://duckduckgo.com/?q=${encodeURIComponent(query)}`; +} + +/** + * Convert address-bar input into a safe, loadable URL. + * + * Only explicit HTTP(S) URLs and complete domain/IP inputs are navigated to. + * Everything else, including unsupported schemes and domain-looking text with + * spaces, becomes a search query instead of reaching the WebView as a URL. + */ +export function normalizeUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return HOME; + + if (/^https?:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? parsed.toString() + : searchUrl(trimmed); + } catch { + return searchUrl(trimmed); + } + } + + if (DOMAIN_OR_IP.test(trimmed)) { + try { + return new URL(`https://${trimmed}`).toString(); + } catch { + return searchUrl(trimmed); + } + } + + return searchUrl(trimmed); +} diff --git a/apps/mobile/src/screens/BrowserScreen.tsx b/apps/mobile/src/screens/BrowserScreen.tsx index f16785b..be377f9 100644 --- a/apps/mobile/src/screens/BrowserScreen.tsx +++ b/apps/mobile/src/screens/BrowserScreen.tsx @@ -9,6 +9,7 @@ import { View, } from 'react-native'; import { WebView } from 'react-native-webview'; +import { HOME, normalizeUrl } from '../lib/navigation'; import { theme } from '../theme'; /** @@ -19,17 +20,6 @@ import { theme } from '../theme'; * Ungoogled Chromium engine (see docs/mobile-architecture.md — the engine ships * via the native Android build and the Linux-phone desktop build, not Expo). */ -const HOME = 'https://tronbrowser.dev'; - -function normalizeUrl(input: string): string { - const trimmed = input.trim(); - if (!trimmed) return HOME; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - // A bare domain-looking string → https; otherwise treat as a search query. - if (/^[\w-]+(\.[\w-]+)+/.test(trimmed)) return `https://${trimmed}`; - return `https://duckduckgo.com/?q=${encodeURIComponent(trimmed)}`; -} - export function BrowserScreen() { const webRef = useRef(null); const [address, setAddress] = useState(HOME);