23、安全测试与权限测试:测试运行时权限、测试安全配置、测试数据加密、测试网络安全性、测试WebView
安全测试,说白了就是给你的App上把锁。我见过太多开发者在功能测试上花了大把时间,结果安全方面一塌糊涂。用户数据泄露、权限滥用、WebView被注入恶意脚本……这些坑,我几乎都踩过。
今天我们就来聊聊,怎么用Kotlin写好安全相关的测试。我会把运行时权限、安全配置、数据加密、网络安全、WebView这几个核心点,一个一个拆开讲。
核心观点:安全测试不是上线前的“临时抱佛脚”,而是应该融入日常的测试流程中。你想想看,一个权限弹窗没处理好,可能就被恶意应用钻了空子。
23.1 测试运行时权限
Android 6.0之后,敏感权限需要运行时申请。这个机制本身没问题,但测试起来挺麻烦的——因为你需要模拟用户“同意”或“拒绝”两种状态。
我个人习惯用 ActivityScenario 配合 GrantPermissionRule 来做权限测试。举个例子:
@RunWith(AndroidJUnit4::class)
class LocationPermissionTest {
@get:Rule
val grantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.ACCESS_FINE_LOCATION
)
@Test
fun testLocationPermissionGranted() {
val scenario = ActivityScenario.launch(MainActivity::class.java)
scenario.onActivity { activity ->
// 权限已经被授予,可以直接调用位置相关API
assertTrue(activity.hasLocationPermission())
}
}
}
这里有个坑——GrantPermissionRule 只能用于测试环境,它会在测试启动时自动授予权限。但如果你要测试“用户拒绝权限”的场景,就得换种方式。
我的经验:我曾经在测试一个地图应用时,发现权限拒绝后App直接崩溃了。原因很简单——没做权限拒绝的兜底处理。所以,一定要写一个“拒绝权限”的测试用例。
拒绝权限的测试,可以用 UiAutomator 或者模拟系统弹窗。不过更简单的方式是,直接 mock 权限检查的结果:
@Test
fun testLocationPermissionDenied() {
// 模拟权限被拒绝
Mockito.`when`(ContextCompat.checkSelfPermission(
anyContext(),
eq(Manifest.permission.ACCESS_FINE_LOCATION)
)).thenReturn(PackageManager.PERMISSION_DENIED)
val scenario = ActivityScenario.launch(MainActivity::class.java)
// 验证是否显示了权限解释弹窗
onView(withText("需要位置权限")).check(matches(isDisplayed()))
}
23.2 测试安全配置
安全配置这块,很多人容易忽略。比如 AndroidManifest.xml 里的 android:allowBackup、android:exported 这些属性,一旦配错,后果很严重。
我记得有一次,一个第三方SDK的Activity忘了设置 exported=false,结果被其他应用直接调用了。嗯,从那以后,我每次都会写一个安全配置的测试。
用 ManifestTest 来检查:
@Test
fun testManifestSecurityConfig() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val pm = appContext.packageManager
// 检查allowBackup是否被禁用
val flags = pm.getApplicationInfo(appContext.packageName, 0).flags
assertTrue(flags and ApplicationInfo.FLAG_ALLOW_BACKUP == 0)
}
另外,network_security_config.xml 也是重点。我建议用 @Config 注解来模拟不同的网络环境:
@RunWith(AndroidJUnit4::class)
@Config(networkSecurityPolicy = NetworkSecurityPolicy::class)
class NetworkSecurityConfigTest {
@Test
fun testCleartextTrafficBlocked() {
// 验证明文流量被拦截
assertThrows(IOException::class.java) {
URL("http://example.com").openConnection()
}
}
}
注意:千万不要在正式环境中使用 android:usesCleartextTraffic="true"。我见过有团队为了调试方便,把这个开关打开了,结果上线后用户数据被中间人攻击。血的教训。
23.3 测试数据加密
数据加密这块,我推荐用 EncryptedSharedPreferences 和 EncryptedFile。但光用不行,你得测试它是不是真的加密了。
怎么测?直接读文件内容,看看是不是乱码:
@Test
fun testEncryptedSharedPreferences() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val sharedPreferences = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// 写入数据
sharedPreferences.edit().putString("token", "secret_value").apply()
// 验证文件内容不是明文
val prefsFile = File(context.filesDir.parent + "/shared_prefs/secure_prefs.xml")
val content = prefsFile.readText()
assertFalse(content.contains("secret_value"))
}
你可能会问,为什么要测这个?说白了,就是防止有人改了加密方案,结果变成“假加密”。我遇到过,一个同事把 EncryptedSharedPreferences 换成了普通的 SharedPreferences,数据全明文存储了。还好测试发现了。
23.4 测试网络安全性
网络安全性测试,核心就是两件事:HTTPS证书校验、请求拦截。
用 MockWebServer 配合自签名证书,可以模拟各种网络攻击场景:
@Test
fun testHttpsCertificatePinning() {
val mockWebServer = MockWebServer()
// 使用自签名证书模拟中间人攻击
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(TrustAllCerts()), SecureRandom())
mockWebServer.useHttps(sslContext.socketFactory, false)
mockWebServer.enqueue(MockResponse().setBody("{\"data\":\"test\"}"))
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
)
.build()
assertThrows(SSLPeerUnverifiedException::class.java) {
client.newCall(Request.Builder()
.url(mockWebServer.url("/"))
.build()).execute()
}
}
避坑指南:我曾经在测试证书固定时,忘了更新测试环境的证书指纹。结果测试一直报错,折腾了半天才发现是证书过期了。所以,记得把测试证书和正式证书分开管理。
23.5 测试WebView
WebView 是安全重灾区。JavaScript 注入、跨站脚本攻击、明文存储……问题太多了。
我建议用 WebViewClient 的 shouldOverrideUrlLoading 来做安全校验:
@Test
fun testWebViewJavaScriptEnabled() {
val webView = WebView(InstrumentationRegistry.getInstrumentation().targetContext)
val settings = webView.settings
// 验证JavaScript默认是禁用的
assertFalse(settings.javaScriptEnabled)
// 验证文件访问被禁用
assertFalse(settings.allowFileAccess)
assertFalse(settings.allowContentAccess)
}
还有一个容易被忽略的点——WebView 的 addJavascriptInterface。这个接口如果暴露了敏感方法,恶意网页就能直接调用。我建议写个测试来验证:
@Test
fun testWebViewJavascriptInterface() {
val webView = WebView(InstrumentationRegistry.getInstrumentation().targetContext)
val jsInterface = MyJsInterface()
webView.addJavascriptInterface(jsInterface, "Android")
// 验证接口方法是否安全
val methods = jsInterface.javaClass.methods
for (method in methods) {
// 检查是否有@JavascriptInterface注解
if (method.isAnnotationPresent(JavascriptInterface::class.java)) {
// 验证方法不会泄露敏感信息
assertFalse(method.name.contains("password"))
assertFalse(method.name.contains("token"))
}
}
}
警告:千万不要在 WebView 中启用 setAllowFileAccess(true)。我见过一个案例,攻击者通过 file:// 协议读取了本地数据库文件。这个漏洞一旦被利用,用户数据就全完了。
知识体系总览
下面这张图,把安全测试的核心脉络梳理了一下。你可以把它当作一个检查清单:
这张图把安全测试分成了五个维度。每个维度下面,都有对应的测试工具和方法。你写测试的时候,对着这张图一个个过,基本不会漏掉什么。
好了,安全测试的内容就这些。记住一句话:安全不是功能,安全是习惯。把测试写好了,上线才能睡得安稳。