14、输入验证与净化:输入长度检查、字符集过滤、白名单与黑名单策略

说到输入验证,我脑子里第一个蹦出来的画面,是几年前一个深夜的线上故障。用户提交了一段看似正常的评论,结果后台数据库直接崩了。查了半天,发现是有人往输入框里塞了一段精心构造的 SQL 语句。嗯,从那以后,我对「用户输入」这四个字就格外敏感。

说白了,你永远不能相信用户输入的数据。这不是偏见,这是安全底线。今天我们就来聊聊,怎么给输入数据「上枷锁」。

为什么输入验证这么重要?

你想想看,你的程序就像一座城堡。用户输入就是城门。如果不设防,谁都能进来,那城堡里再好的防御也没用。

常见的攻击手法,比如 SQL 注入、XSS 攻击、缓冲区溢出,根源往往都在输入验证上。我见过太多新手,觉得「用户不会那么坏」,结果被现实狠狠教育了一顿。

核心原则: 信任边界。所有外部数据,包括用户输入、网络请求、文件读取,都必须经过验证和净化。

输入长度检查

这是最基础,也是最容易被忽略的一步。长度检查能防止缓冲区溢出,也能限制攻击者构造超长 payload。

我个人习惯,在接收任何字符串输入时,先做长度检查。比如用户名,我一般限制在 32 个字符以内。密码可以长一点,但也不能无限长。

// C 语言示例:安全地读取输入
#include <stdio.h>
#include <string.h>

#define MAX_USERNAME_LEN 32
#define BUFFER_SIZE 256

int safe_read_input(char *buffer, size_t max_len) {
    if (fgets(buffer, max_len, stdin) == NULL) {
        return -1; // 读取失败
    }
    
    // 去掉末尾的换行符
    size_t len = strlen(buffer);
    if (len > 0 && buffer[len-1] == '\n') {
        buffer[len-1] = '\0';
    }
    
    // 检查是否被截断(输入过长)
    if (strlen(buffer) == max_len - 1 && buffer[max_len-2] != '\n') {
        // 输入过长,需要清空缓冲区
        int c;
        while ((c = getchar()) != '\n' && c != EOF);
        return -2; // 输入过长
    }
    
    return 0;
}

int main() {
    char username[BUFFER_SIZE];
    
    printf("请输入用户名(最多 %d 个字符): ", MAX_USERNAME_LEN);
    
    if (safe_read_input(username, MAX_USERNAME_LEN + 1) != 0) {
        printf("输入无效!\n");
        return 1;
    }
    
    printf("用户名: %s\n", username);
    return 0;
}

小技巧: 长度检查要放在最前面。先检查长度,再做其他验证。这样可以避免处理超长字符串时出现性能问题。

字符集过滤

长度检查只是第一步。接下来,我们要看看输入里到底有什么字符。

字符集过滤,说白了就是只允许特定范围内的字符通过。比如用户名,我一般只允许字母、数字和下划线。其他字符,一律拒绝。

我在项目中遇到过一个问题:用户注册时,名字里带了特殊符号,结果在生成文件路径时出了问题。从那以后,我对字符集过滤就格外严格。

// 字符集过滤示例
#include <ctype.h>
#include <stdbool.h>

bool is_valid_username(const char *input) {
    if (input == NULL) return false;
    
    for (int i = 0; input[i] != '\0'; i++) {
        // 只允许字母、数字、下划线
        if (!isalnum(input[i]) && input[i] != '_') {
            return false;
        }
    }
    
    return true;
}

// 更严格的版本:只允许 ASCII 可见字符
bool is_ascii_printable(const char *input) {
    if (input == NULL) return false;
    
    for (int i = 0; input[i] != '\0'; i++) {
        if (input[i] < 32 || input[i] > 126) {
            return false;
        }
    }
    
    return true;
}

注意: 字符集过滤不能只做一次。如果数据经过了编码转换(比如 URL 解码、Base64 解码),要在解码之后再做过滤。

白名单与黑名单策略

这是两种截然不同的思路。我强烈推荐白名单策略。

黑名单策略: 列出不允许的字符或模式。比如过滤掉 <script>DROP TABLE 等。

白名单策略: 列出允许的字符或模式。其他的一律拒绝。

为什么我推荐白名单?因为黑名单永远有漏洞。攻击者总能找到你没想到的变种。比如你过滤了 <script>,人家用 <ScRiPt> 就绕过去了。你过滤了 DROP,人家用 DrOp 照样能跑。

白名单就简单多了。你只允许字母数字,那其他字符根本进不来。攻击者再厉害,也变不出花来。

策略 优点 缺点 适用场景
白名单 安全性高,不易绕过 可能限制合法输入 用户名、密码、路径
黑名单 灵活,允许大部分输入 容易遗漏,安全性低 日志过滤、简单文本处理

输入验证与净化的完整流程

下面这张图,是我自己总结的输入处理流程。每次写代码,我都会对照着走一遍。

输入验证与净化流程 接收原始输入 长度检查 字符集过滤 白名单/黑名单匹配 通过 / 拒绝 原始数据,不做任何假设 防止缓冲区溢出 只允许安全字符 推荐使用白名单 拒绝则返回错误

实战中的避坑指南

我曾经接手过一个项目,代码里做了输入验证,但只在前端做了。后端直接信任了前端传过来的数据。结果呢?攻击者绕过前端,直接向后端发请求,数据就裸奔了。

记住:前端验证只是用户体验,后端验证才是安全底线。

还有一次,我看到有人用 strlen() 检查输入长度,但没考虑宽字符。用户输入了中文字符,一个中文字符占多个字节,长度检查就失效了。嗯,这里要注意,长度检查要基于实际存储的字节数,而不是字符数。

总结一下我的经验:

  • 先做长度检查,再做内容检查
  • 优先使用白名单策略
  • 前端后端都要做验证
  • 验证要在解码之后进行
  • 拒绝非法输入,不要尝试修复

最后说一句:输入验证不是一劳永逸的事。攻击手法在进化,你的验证逻辑也要跟着更新。定期 review 你的验证代码,看看有没有遗漏的边界情况。

好了,这一章的内容就到这里。记住,安全无小事,从输入开始。


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