安全最佳实践:SQL 注入防护、路径遍历防护、数据加密

说实话,Content Provider 的安全问题,我见过太多人栽跟头了。我自己早期做项目时,也踩过几个坑。今天咱们就把这三个最要命的安全问题——SQL 注入、路径遍历、数据加密——彻底聊透。

一、SQL 注入防护:别让查询变成灾难

先问个问题:你的 Content Provider 是怎么处理外部传入的查询条件的?

很多人习惯直接拼接字符串:

// 危险!千万别这么写
String selection = "name = '" + userInput + "'";
Cursor cursor = db.query("users", null, selection, null, null, null, null);

嗯,这就是典型的 SQL 注入漏洞。用户输入 ' OR 1=1 --,你的整个表就裸奔了。

正确的做法是什么? 用参数化查询:

// 安全写法
String selection = "name = ?";
String[] selectionArgs = new String[]{userInput};
Cursor cursor = db.query("users", null, selection, selectionArgs, null, null, null);

我个人习惯,只要涉及外部输入,一律用 ? 占位符。哪怕你觉得这个输入“肯定安全”,也别偷懒。我曾经在一个项目中,就因为觉得“用户 ID 是数字,不会有问题”,直接拼了字符串。结果 QA 同学传了个 1; DROP TABLE users; --,测试库直接崩了。嗯,从那以后我再也不敢了。

⚠️ 注意: 即使你用了 selectionArgs,也要注意 LIKE 查询中的 %_ 通配符。用户输入中如果包含这些字符,可能会匹配到意料之外的数据。建议对用户输入做转义处理。

二、路径遍历防护:别让文件暴露在外

如果你的 Content Provider 提供了文件访问功能(比如通过 openFile()openAssetFile()),那路径遍历就是你必须面对的敌人。

什么叫路径遍历?说白了,就是用户传个 ../../data/data/com.example/databases/secret.db,想绕过你的限制去读不该读的文件。

我见过最典型的错误写法:

// 危险!直接拼接路径
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
    String path = uri.getLastPathSegment();
    File file = new File(getContext().getFilesDir(), path);
    return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
}

用户传个 content://com.example.provider/../../sdcard/secret.txt,你的 getLastPathSegment() 拿到的是 ../../sdcard/secret.txt,然后直接拼到 getFilesDir() 后面……后果你自己想。

怎么防? 两步走:

  1. 规范化路径:用 File.getCanonicalPath() 把路径转成标准形式。
  2. 检查前缀:确保最终路径在你允许的目录下。
// 安全写法
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
    String path = uri.getLastPathSegment();
    File targetFile = new File(getContext().getFilesDir(), path);
    
    try {
        String canonicalPath = targetFile.getCanonicalPath();
        String allowedDir = getContext().getFilesDir().getCanonicalPath();
        
        // 检查路径是否在允许的目录下
        if (!canonicalPath.startsWith(allowedDir)) {
            throw new SecurityException("访问被拒绝");
        }
        
        return ParcelFileDescriptor.open(targetFile, ParcelFileDescriptor.MODE_READ_ONLY);
    } catch (IOException e) {
        throw new FileNotFoundException("文件不存在");
    }
}
💡 小技巧: 如果你只需要提供特定文件,干脆用 UriMatcher 做白名单匹配。只允许访问你明确列出的文件,其他一概拒绝。这样最省心。

三、数据加密:敏感数据必须上锁

Content Provider 存储的数据,很多都是敏感信息——用户密码、支付信息、个人资料。这些数据如果明文存储,一旦设备被 root 或者备份文件被窃取,后果不堪设想。

我个人建议,敏感数据在写入 Content Provider 之前就应该加密。不要指望 Content Provider 本身来做加密,因为加密密钥的管理是个大问题。

Android 提供了 EncryptedSharedPreferencesAndroid Keystore,但如果你用的是 SQLite 数据库,我推荐用 SQLCipher

// 使用 SQLCipher 加密数据库
SQLiteDatabase database = SQLiteDatabase.openOrCreateDatabase(
    databaseFile, 
    "your_encryption_key", 
    null
);

当然,密钥不能硬编码在代码里。我习惯把密钥存在 Android Keystore 中,只有应用自身能读取:

// 生成并存储密钥到 Keystore
KeyGenerator keyGenerator = KeyGenerator.getInstance(
    KeyProperties.KEY_ALGORITHM_AES, 
    "AndroidKeyStore"
);
keyGenerator.init(
    new KeyGenParameterSpec.Builder("my_key", 
        KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        .build()
);
SecretKey key = keyGenerator.generateKey();

这里有个坑:密钥轮换。我曾经在一个金融类项目中,密钥用了两年没换。后来安全审计时被要求整改。建议定期更换密钥,并且旧密钥要妥善销毁。

📌 核心原则: 加密不是万能的,但不加密是万万不能的。即使你觉得数据“没那么敏感”,也建议加密。因为“没那么敏感”和“完全不敏感”之间,往往只差一次安全事件。

知识体系总览

下面这张图,把今天讲的三个安全维度和它们的关系梳理了一下:

Content Provider 安全最佳实践 SQL 注入防护 路径遍历防护 数据加密 具体措施 • 使用参数化查询 • 避免字符串拼接 • 转义 LIKE 通配符 具体措施 • 规范化路径 • 检查路径前缀 • 使用白名单机制 具体措施 • 使用 SQLCipher • 密钥存 Keystore • 定期密钥轮换 安全是设计出来的,不是测试出来的

总结一下

今天聊的三个安全点,其实核心就一句话:永远不要信任外部输入。不管是 SQL 查询参数、文件路径,还是存储的数据,都要假设它们可能被恶意利用。

我个人的经验是,安全防护要尽早做。等项目上线了再回头补安全,成本高不说,还容易漏。你想想看,一个 SQL 注入漏洞,可能从你写第一行代码时就埋下了。等到用户数据泄露了再追责,那就晚了。

嗯,今天就聊到这儿。记住:安全无小事,细节定成败。


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