Android app 在 WebView 中支付是否安全?
结论
在 WebView 中完成支付并不一定不安全,但通常比直接使用系统浏览器或支付机构提供的原生 SDK 风险更高。
差别主要在页面的运行环境:
- 系统浏览器有用户熟悉的地址栏、证书提示、反钓鱼机制,安全边界也更清楚。
- WebView 由宿主 app 控制。app 可以注入 JavaScript、读取或修改页面内容、拦截导航。配置不当时,还可能向网页暴露本地接口。
- 用户很难在 WebView 中确认当前页面的真实域名,也无法判断 app 是否修改了支付页面。
通常可以按以下顺序选择:
- 支付服务商官方 Android SDK。
- Android Custom Tabs 或系统浏览器。
- 经过严格限制的 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 可能被写入日志、历史记录、代理记录或分析系统。建议由服务端创建短期支付会话,客户端只持有不含敏感信息的一次性标识。
服务端确认支付结果
安全流程可以按以下步骤设计:
- app 请求业务服务端创建订单。
- 服务端向支付平台创建支付会话。
- app 打开支付平台返回的短期 HTTPS 地址。
- 用户完成支付。
- 支付平台通过签名 webhook 通知业务服务端。
- 服务端验证签名、订单号、金额、币种和支付状态。
- 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。
备注:内容仅供参考。
