Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,70 @@ import com.flowfoundation.wallet.utils.saveToFile
import org.apache.commons.validator.routines.UrlValidator
import java.io.File
import java.net.URL
import java.net.URI

/**
* Security utility to extract the actual host from a URL.
* Handles URL spoofing attempts like "https://trusted.com@malicious.com"
* where the "@" symbol makes "trusted.com" appear as the username, not the host.
*
* @return The actual host being accessed, or the full URL if parsing fails
*/
fun String.extractActualHost(): String {
if (this.isBlank()) return this
return try {
val uri = URI(this)
uri.host ?: this
} catch (e: Exception) {
this
}
}

/**
* Security check to detect if a URL contains a potentially deceptive "@" symbol
* in the authority component (e.g., "https://trusted.com@malicious.com").
* This is a common URL spoofing technique.
*
* @return true if the URL contains a suspicious "@" pattern that could be used for spoofing
*/
fun String.hasDeceptiveAtSymbol(): Boolean {
if (this.isBlank()) return false
return try {
val uri = URI(this)
// Check if there's a userInfo component (part before @)
// that looks like a domain (contains a dot)
val userInfo = uri.userInfo
userInfo != null && userInfo.contains(".")
} catch (e: Exception) {
// Fallback: check if there's an @ between :// and the next /
val protocolEnd = this.indexOf("://")
if (protocolEnd == -1) return false
val pathStart = this.indexOf("/", protocolEnd + 3)
val authority = if (pathStart == -1) this.substring(protocolEnd + 3) else this.substring(protocolEnd + 3, pathStart)
authority.contains("@") && authority.substringBefore("@").contains(".")
}
}

/**
* Returns a safe display string for the URL to show in the address bar.
* Shows the full URL but highlights potential security issues.
* For deceptive URLs with @, returns the actual host being accessed.
*
* @return Safe display string showing the actual destination
*/
fun String.toSafeDisplayUrl(): String {
if (this.isBlank()) return this
return try {
val uri = URI(this)
val host = uri.host ?: return this
val scheme = uri.scheme ?: "https"
val port = if (uri.port != -1 && uri.port != 80 && uri.port != 443) ":${uri.port}" else ""
val path = uri.path ?: ""
"$scheme://$host$port$path"
} catch (e: Exception) {
this
}
}

fun openBrowser(
activity: Activity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.flowfoundation.wallet.page.window.bubble.tools.inBubbleStack
import com.flowfoundation.wallet.utils.extensions.isVisible
import com.flowfoundation.wallet.utils.extensions.setVisible
import com.flowfoundation.wallet.utils.uiScope
import com.flowfoundation.wallet.utils.logd

class BrowserPresenter(
private val binding: LayoutBrowserBinding,
Expand Down Expand Up @@ -82,10 +83,40 @@ class BrowserPresenter(
}

override fun onTitleChange(title: String) {
binding.titleView.text = title.ifBlank { webview()?.url }
// SECURITY FIX: Do NOT use the page title for address bar display
// The title can be spoofed by attackers to show fake URLs
// Instead, we always display the actual URL via onPageUrlChange
// The title is intentionally ignored here to prevent address bar spoofing
logd(TAG, "Page title received (not displayed in address bar): $title")
}

override fun onPageUrlChange(url: String, isReload: Boolean) {
// SECURITY FIX: Always display the actual sanitized URL, never the page title
updateAddressBar(url)
}

/**
* Safely updates the address bar with the actual URL being displayed.
* This method sanitizes URLs to prevent spoofing attacks.
*
* @param url The URL to display (will be sanitized)
*/
private fun updateAddressBar(url: String) {
if (url.isBlank()) return

// Use the sanitized URL that strips out deceptive @ symbols
val safeUrl = url.toSafeDisplayUrl()

// Log if a deceptive URL was detected
if (url.hasDeceptiveAtSymbol()) {
logd(TAG, "SECURITY: Deceptive URL detected with @ symbol. Original: $url, Displayed: $safeUrl")
}

binding.titleView.text = safeUrl.extractActualHost()
}

companion object {
private const val TAG = "BrowserPresenter"
}

override fun onWindowColorChange(color: Int) {
Expand All @@ -102,13 +133,15 @@ class BrowserPresenter(
newAndPushBrowserTab(url)?.let { tab ->
tab.webView.setWebViewCallback(this@BrowserPresenter)
WindowFrame.browserContainer()?.post { expandBrowser() }
onTitleChange(tab.title() ?: (tab.url().orEmpty()))
// SECURITY FIX: Always display the URL, never the page title
updateAddressBar(tab.url().orEmpty())
}
}

private fun onBrowserTabChange() {
WindowFrame.browserContainer()?.setVisible(true)
onTitleChange(webview()?.title.orEmpty())
// SECURITY FIX: Always display the URL, never the page title
updateAddressBar(webview()?.url.orEmpty())
}

fun handleBackPressed(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flowfoundation.wallet.page.browser.widgets

import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
Expand All @@ -13,13 +14,18 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.JsPromptResult
import android.webkit.JsResult
import android.webkit.ValueCallback
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.ColorInt
import com.flowfoundation.wallet.page.browser.extractActualHost
import com.flowfoundation.wallet.page.browser.hasDeceptiveAtSymbol
import com.crowdin.platform.Crowdin
import com.flowfoundation.wallet.BuildConfig
import com.flowfoundation.wallet.R
Expand Down Expand Up @@ -183,6 +189,38 @@ class LilicoWebView : WebView {
blockedViewLayout.visibility = View.GONE
}

/**
* Shows a security warning dialog for URLs that contain deceptive patterns
* like "@" symbols that could be used for URL spoofing attacks.
*
* @param url The potentially deceptive URL
* @param actualHost The actual host that will be accessed
* @param onProceed Callback when user chooses to proceed anyway
* @param onCancel Callback when user cancels navigation
*/
private fun showDeceptiveUrlWarning(
url: String,
actualHost: String,
onProceed: () -> Unit,
onCancel: () -> Unit
) {
uiScope {
AlertDialog.Builder(context)
.setTitle(R.string.security_warning)
.setMessage(context.getString(R.string.deceptive_url_warning, actualHost))
.setPositiveButton(R.string.proceed_anyway) { dialog, _ ->
dialog.dismiss()
onProceed()
}
.setNegativeButton(R.string.cancel) { dialog, _ ->
dialog.dismiss()
onCancel()
}
.setCancelable(false)
.show()
}
}

fun setWebViewCallback(callback: WebviewCallback?) {
this.callback = callback
}
Expand Down Expand Up @@ -217,6 +255,112 @@ class LilicoWebView : WebView {
uiScope { showWebviewFilePicker(context, filePathCallback, fileChooserParams) }
return true
}

/**
* SECURITY FIX: Override JavaScript alert dialogs to clearly indicate
* they are from a webpage, not from Flow Wallet app itself.
* This prevents phishing attacks via fake app dialogs.
*/
override fun onJsAlert(
view: WebView?,
url: String?,
message: String?,
result: JsResult?
): Boolean {
val host = url?.extractActualHost() ?: "Unknown"
logd(TAG, "SECURITY: JS Alert intercepted from: $host")

AlertDialog.Builder(context)
.setTitle(context.getString(R.string.js_dialog_title, host))
.setMessage(message)
.setPositiveButton(R.string.ok) { dialog, _ ->
result?.confirm()
dialog.dismiss()
}
.setOnCancelListener {
result?.cancel()
}
.setCancelable(true)
.show()
return true
}

/**
* SECURITY FIX: Override JavaScript confirm dialogs to clearly indicate
* they are from a webpage, not from Flow Wallet app itself.
*/
override fun onJsConfirm(
view: WebView?,
url: String?,
message: String?,
result: JsResult?
): Boolean {
val host = url?.extractActualHost() ?: "Unknown"
logd(TAG, "SECURITY: JS Confirm intercepted from: $host")

AlertDialog.Builder(context)
.setTitle(context.getString(R.string.js_dialog_title, host))
.setMessage(message)
.setPositiveButton(R.string.ok) { dialog, _ ->
result?.confirm()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel) { dialog, _ ->
result?.cancel()
dialog.dismiss()
}
.setOnCancelListener {
result?.cancel()
}
.setCancelable(true)
.show()
return true
}

/**
* SECURITY FIX: Override JavaScript prompt dialogs to clearly indicate
* they are from a webpage and NOT from Flow Wallet app.
* This is critical to prevent phishing attacks that display fake login prompts.
*
* The dialog clearly shows which website is requesting input, making it
* obvious to users that this is NOT a Flow Wallet system dialog.
*/
override fun onJsPrompt(
view: WebView?,
url: String?,
message: String?,
defaultValue: String?,
result: JsPromptResult?
): Boolean {
val host = url?.extractActualHost() ?: "Unknown"
logd(TAG, "SECURITY: JS Prompt intercepted from: $host - Message: $message")

// Create an EditText for user input
val inputView = EditText(context).apply {
setText(defaultValue)
hint = context.getString(R.string.js_prompt_hint)
setPadding(48, 32, 48, 32)
}

AlertDialog.Builder(context)
.setTitle(context.getString(R.string.js_dialog_title, host))
.setMessage(message)
.setView(inputView)
.setPositiveButton(R.string.ok) { dialog, _ ->
result?.confirm(inputView.text.toString())
dialog.dismiss()
}
.setNegativeButton(R.string.cancel) { dialog, _ ->
result?.cancel()
dialog.dismiss()
}
.setOnCancelListener {
result?.cancel()
}
.setCancelable(true)
.show()
return true
}
}

private inner class WebViewClient : android.webkit.WebViewClient() {
Expand Down Expand Up @@ -284,18 +428,54 @@ class LilicoWebView : WebView {
}

val uri = request.url
val urlString = uri.toString()
logd(TAG, "shouldOverrideUrlLoading URL: $uri, scheme: ${uri.scheme}")

// Check if it's an about:blank#blocked URL (internal for blocked pages)
if (uri.toString() == "about:blank#blocked") {
if (urlString == "about:blank#blocked") {
return true
}

// SECURITY FIX: Block javascript: URL scheme to prevent UXSS attacks
// Attackers can use javascript: URLs in iframes to execute arbitrary JS
// and display fake UI elements (like login prompts) that look like app dialogs
if (uri.scheme?.lowercase() == "javascript") {
logd(TAG, "SECURITY: Blocked javascript: URL scheme - $urlString")
return true
}

// SECURITY CHECK: Detect deceptive URLs with @ symbol
// URLs like "https://trusted.com@malicious.com" are spoofing attempts
if (urlString.hasDeceptiveAtSymbol()) {
val actualHost = urlString.extractActualHost()
logd(TAG, "SECURITY: Deceptive URL detected. URL: $urlString, Actual host: $actualHost")

// Stop loading and show warning
view?.stopLoading()
isLoading = false

showDeceptiveUrlWarning(
url = urlString,
actualHost = actualHost,
onProceed = {
// User chose to proceed despite warning
logd(TAG, "User proceeded to deceptive URL: $urlString")
isLoading = true
view?.loadUrl(urlString)
},
onCancel = {
// User cancelled navigation
logd(TAG, "User cancelled navigation to deceptive URL: $urlString")
}
)
return true
}

// Check if URL is blocked - this must be done on UI thread
uiScope {
if (BlockManager.isBlocked(uri.toString())) {
if (BlockManager.isBlocked(urlString)) {
logd(TAG, "URL blocked: $uri")
showBlockedViewLayout(uri.toString())
showBlockedViewLayout(urlString)
loadUrl("about:blank#blocked")
isLoading = false
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -810,4 +810,9 @@
<string name="confirm_copy_address_tips">You have copied an EVM address on the Flow network, please make sure you only send assets on the Flow network to this address otherwise they will be lost.</string>
<string name="evm_on_flow_address">EVM on Flow address</string>
<string name="recover_profile">Recover profile</string>
<string name="security_warning">Security Warning</string>
<string name="deceptive_url_warning">This link may be trying to trick you. The actual destination is: %1$s\n\nThis URL contains characters that could be used to impersonate another website. Proceed with caution.</string>
<string name="proceed_anyway">Proceed Anyway</string>
<string name="js_dialog_title">Message from %1$s</string>
<string name="js_prompt_hint">Enter your response</string>
</resources>