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:allowBackupandroid: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 测试数据加密

数据加密这块,我推荐用 EncryptedSharedPreferencesEncryptedFile。但光用不行,你得测试它是不是真的加密了。

怎么测?直接读文件内容,看看是不是乱码:

@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 注入、跨站脚本攻击、明文存储……问题太多了。

我建议用 WebViewClientshouldOverrideUrlLoading 来做安全校验:

@Test
fun testWebViewJavaScriptEnabled() {
    val webView = WebView(InstrumentationRegistry.getInstrumentation().targetContext)
    val settings = webView.settings

    // 验证JavaScript默认是禁用的
    assertFalse(settings.javaScriptEnabled)

    // 验证文件访问被禁用
    assertFalse(settings.allowFileAccess)
    assertFalse(settings.allowContentAccess)
}

还有一个容易被忽略的点——WebViewaddJavascriptInterface。这个接口如果暴露了敏感方法,恶意网页就能直接调用。我建议写个测试来验证:

@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:// 协议读取了本地数据库文件。这个漏洞一旦被利用,用户数据就全完了。

知识体系总览

下面这张图,把安全测试的核心脉络梳理了一下。你可以把它当作一个检查清单:

安全测试 运行时权限 安全配置 数据加密 网络安全 WebView安全 GrantPermissionRule 拒绝权限模拟 Manifest检查 网络配置 EncryptedSharedPrefs MockWebServer 证书固定测试 JS接口检查 文件访问禁用

这张图把安全测试分成了五个维度。每个维度下面,都有对应的测试工具和方法。你写测试的时候,对着这张图一个个过,基本不会漏掉什么。

好了,安全测试的内容就这些。记住一句话:安全不是功能,安全是习惯。把测试写好了,上线才能睡得安稳。

公众号:蓝海资料掘金营,微信deep3321