PAYATHON 2026

Android app 在 WebView 中支付是否安全?

支付小周

结论

在 WebView 中完成支付并不一定不安全,但通常比直接使用系统浏览器或支付机构提供的原生 SDK 风险更高。

差别主要在页面的运行环境:

  • 系统浏览器有用户熟悉的地址栏、证书提示、反钓鱼机制,安全边界也更清楚。
  • WebView 由宿主 app 控制。app 可以注入 JavaScript、读取或修改页面内容、拦截导航。配置不当时,还可能向网页暴露本地接口。
  • 用户很难在 WebView 中确认当前页面的真实域名,也无法判断 app 是否修改了支付页面。

通常可以按以下顺序选择:

  1. 支付服务商官方 Android SDK。
  2. Android Custom Tabs 或系统浏览器。
  3. 经过严格限制的 WebView,仅在业务确有需要且支付服务商明确支持时使用。

无论采用哪种方式,服务端都必须独立验证支付结果,不能直接相信 WebView 返回的“支付成功”。

两种方式的主要安全差异

系统浏览器

系统浏览器通常有以下安全能力:

  • 显示域名和 HTTPS 状态。
  • 页面运行环境独立于 app。
  • 由浏览器提供安全更新、恶意网站检测和权限控制。
  • Cookie、密码管理和自动填充的边界相对清楚。
  • 用户可以识别证书错误、异常跳转和钓鱼域名。

恶意 app 仍可能通过界面覆盖、滥用辅助功能等方式攻击用户,但普通 app 无法直接控制浏览器中的支付页面。

WebView

WebView 的网络和渲染能力通常接近现代浏览器,但其安全性还取决于宿主 app 的实现。常见风险包括:

  • app 可以通过 evaluateJavascript() 注入或执行脚本。
  • 配置不安全的 addJavascriptInterface() 可能向网页暴露原生能力。
  • URL 校验不严可能让页面跳转到钓鱼站点。
  • 忽略 TLS 证书错误可能导致中间人攻击。
  • 开启文件访问、混合内容或调试功能会扩大攻击面。
  • app 可能读取、记录或转发用户输入。
  • WebView 通常没有完整的地址栏,用户难以确认页面身份。
  • Cookie 可能与 app 内其他 WebView 页面共享。
  • 旧设备或长期未更新的系统 WebView 可能含有已知漏洞。

这不代表合规 app 一定会窃取卡号,而是用户除了信任支付网站,还必须信任宿主 app 及其 WebView 配置。

推荐实现方案

优先使用支付 SDK

如果支付机构提供 Android SDK,应优先采用。正规的支付 SDK 通常会处理:

  • 支付页面或原生控件。
  • 身份认证和跳转。
  • 3-D Secure 等验证流程。
  • app 返回链接。
  • 敏感支付数据的隔离。

能否使用 WebView 支付,还取决于支付机构、收单银行、卡组织规则以及当地的合规要求。部分支付流程会明确禁止嵌入式 WebView,或要求使用系统浏览器。实施前应检查支付服务商当前的文档和合同要求。

使用 Custom Tabs

如果支付流程采用网页形式,Custom Tabs 通常比 WebView 更合适。它保留了浏览器的安全模型和域名展示,同时可以从 app 内启动。

import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent

fun openPaymentPage(paymentUrl: String) {
    val uri = Uri.parse(paymentUrl)

    require(uri.scheme == "https")
    require(uri.host == "pay.example.com")

    CustomTabsIntent.Builder()
        .setShowTitle(true)
        .build()
        .launchUrl(this, uri)
}

支付完成后,可以通过 App Links 返回 app。返回参数只能用于界面跳转,不能作为到账凭证。

服务端应通过支付平台的 webhook、签名通知或支付查询接口确认最终状态。

必须使用 WebView 时如何加固

下面是一个基础示例。具体配置仍需根据支付页面的实际依赖进行调整:

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.webkit.CookieManager
import android.webkit.SslErrorHandler
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import net.example.app.BuildConfig

class PaymentActivity : AppCompatActivity() {

    private lateinit var webView: WebView

    private val allowedHosts = setOf(
        "pay.example.com",
        "secure.payment-provider.example"
    )

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)

        webView = WebView(this)
        setContentView(webView)

        webView.settings.apply {
            javaScriptEnabled = true // 仅在支付页面确实需要时开启
            domStorageEnabled = true  // 同样按页面需求决定

            allowFileAccess = false
            allowContentAccess = false
            mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW

            setSupportMultipleWindows(false)
        }

        CookieManager.getInstance()
            .setAcceptThirdPartyCookies(webView, false)

        webView.webViewClient = object : WebViewClient() {

            override fun shouldOverrideUrlLoading(
                view: WebView,
                request: WebResourceRequest
            ): Boolean {
                val uri = request.url

                if (!isAllowedPaymentUrl(uri)) {
                    // 阻止 WebView 加载未知域名;也可交给受控的外部浏览器处理
                    return true
                }

                return false
            }

            override fun onReceivedSslError(
                view: WebView,
                handler: SslErrorHandler,
                error: android.net.http.SslError
            ) {
                // 不允许用户或代码绕过证书错误
                handler.cancel()
            }

            override fun onPageStarted(
                view: WebView,
                url: String,
                favicon: Bitmap?
            ) {
                if (!isAllowedPaymentUrl(Uri.parse(url))) {
                    view.stopLoading()
                }
            }
        }

        val paymentUri = Uri.parse(getPaymentUrlFromTrustedSource())

        if (!isAllowedPaymentUrl(paymentUri)) {
            finish()
            return
        }

        webView.loadUrl(paymentUri.toString())
    }

    private fun isAllowedPaymentUrl(uri: Uri): Boolean {
        return uri.scheme == "https" &&
            uri.host?.lowercase() in allowedHosts &&
            uri.userInfo == null
    }

    private fun getPaymentUrlFromTrustedSource(): String {
        // 应从受信任的服务端创建一次性支付会话
        return "https://pay.example.com/session/opaque-session-id"
    }

    override fun onDestroy() {
        webView.apply {
            stopLoading()
            loadUrl("about:blank")
            clearHistory()
            removeAllViews()
            destroy()
        }
        super.onDestroy()
    }
}

这个示例只提供安全基线,不能证明整个支付流程已经安全。

实现时的关键要求

严格校验 URL

不要使用简单的字符串判断:

// 错误示例
url.contains("example.com")
url.startsWith("https://example.com")

这些判断可能会接受以下恶意地址:

https://example.com.attacker.test/
https://example.com@attacker.test/

应使用 Uri 解析地址,并检查:

  • scheme 必须是 https。
  • host 必须与允许列表精确匹配。
  • 如果需要允许子域名,应检查域名边界,不能只使用 endsWith("example.com")。
  • 不接受 URL 中的用户名信息。
  • 支付会话标识应不可预测、短期有效,最好只能使用一次。

永远不要忽略 TLS 错误

下面的代码会破坏 HTTPS 的安全性,绝不能用于支付场景:

override fun onReceivedSslError(
    view: WebView,
    handler: SslErrorHandler,
    error: android.net.http.SslError
) {
    handler.proceed() // 不安全
}

出现证书错误时应直接取消加载。即使测试环境使用自签名证书,也不能把忽略错误的逻辑带入正式版本。

避免 JavaScript 原生桥

除非确有必要,不要对支付页面调用:

webView.addJavascriptInterface(nativeBridge, "Android")

如果必须使用,应满足以下要求:

  • WebView 只能加载受信任的 HTTPS 页面。
  • 所有跳转和 iframe 来源都受到控制。
  • 只暴露必要的方法。
  • 原生侧重新校验每个参数。
  • 原生桥不能读取令牌、文件、联系人,也不能执行任意命令。

支付完成状态应通过服务端通知确认,不应由网页调用原生桥并传入 success=true。

不要自动填充或注入凭据

不要把账号、密码、银行卡号或长期访问令牌拼接到 URL 中,也不要通过 JavaScript 自动写入页面:

https://pay.example.com/?password=...   // 不安全

URL 可能被写入日志、历史记录、代理记录或分析系统。建议由服务端创建短期支付会话,客户端只持有不含敏感信息的一次性标识。

服务端确认支付结果

安全流程可以按以下步骤设计:

  1. app 请求业务服务端创建订单。
  2. 服务端向支付平台创建支付会话。
  3. app 打开支付平台返回的短期 HTTPS 地址。
  4. 用户完成支付。
  5. 支付平台通过签名 webhook 通知业务服务端。
  6. 服务端验证签名、订单号、金额、币种和支付状态。
  7. app 从自己的服务端查询订单最终状态。

WebView 中的成功页面、重定向参数或 JavaScript 回调只能改善交互,不能作为发货、充值或开通权益的依据。

其他注意事项

  • 正式版本不要开启 WebView 远程调试。
  • 不要允许 HTTP 内容、混合内容或任意外部跳转。
  • 不要在客户端记录卡号、CVV、密码、Cookie、授权码或完整支付 URL。
  • 支付页面可能依赖第三方 Cookie、弹窗或 3-D Secure 跳转。禁用相关能力后需要完整测试,但不能为了让流程运行而直接放开所有权限。
  • 确保 Android System WebView 或浏览器可以通过系统渠道及时更新。
  • 可以根据风险模型使用 FLAG_SECURE,减少普通截图和最近任务预览造成的信息暴露,但它无法阻止所有录屏、辅助功能攻击或已被攻陷设备上的攻击。
  • PCI DSS 等合规责任不会因为页面放在 WebView 中而自动消失。具体范围取决于支付集成方式和服务商方案,应由支付或合规团队确认。

如果只是复用现有的网页支付流程,Custom Tabs 通常比自行嵌入 WebView 更稳妥。只有当支付服务商明确支持、跳转范围可控、WebView 已完成加固,并且服务端会独立验单时,才适合使用 WebView。

备注:内容仅供参考。