1. 输入验证
为什么注册接口是输入验证的第一道关口
用户注册接口直接接收外部输入,且这些输入会进入数据库、日志、邮件模板等多个下游环节。如果不在入口处做严格校验,攻击者可以通过用户名注入特殊字符、通过邮箱字段夹带超长载荷、通过年龄字段传入负数或超大值来探测系统边界。本节采用白名单校验策略:只允许明确合法的字符和取值范围通过,其余一律拒绝。
白名单校验的核心原则是"默认拒绝"——先定义合法格式,再判断输入是否完全匹配。与之相对的黑名单(禁止特定字符)容易被编码绕过,不适合作为主要防线。
三个字段的校验规则设计
| 字段 | 类型约束 | 白名单规则 | 拒绝示例 |
|---|---|---|---|
| 用户名 | 字符串 | 长度 3–20,仅允许字母、数字、下划线,且必须以字母开头 | admin'--、_user、ab |
| 邮箱 | 字符串 | 长度 ≤ 254,匹配标准邮箱格式,本地部分仅允许字母数字和 . _ % + - | a@b、user@.com、含空格的地址 |
| 年龄 | 整数 | 13 ≤ age ≤ 120,必须是整数,不接受字符串形式的数字 | -1、0、200、"25" |
校验流程与错误聚合
校验不应在第一个错误处就返回,而应收集所有字段的错误后一次性返回结构化信息,方便前端逐项提示。每个错误包含字段名、错误码和可读消息。
关键实现代码
以下实现使用 Node.js 风格,不依赖任何第三方校验库,便于理解白名单校验的底层逻辑。校验器返回 { valid, errors } 结构,调用方根据 valid 决定是否继续。
// validators.js
const USERNAME_RE = /^[A-Za-z][A-Za-z0-9_]{2,19}$/;
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
function validateUsername(value) {
if (typeof value !== 'string') {
return { field: 'username', code: 'TYPE', message: '用户名必须是字符串' };
}
if (value.length < 3 || value.length > 20) {
return { field: 'username', code: 'LENGTH', message: '用户名长度需在 3–20 之间' };
}
if (!USERNAME_RE.test(value)) {
return { field: 'username', code: 'FORMAT', message: '用户名只能包含字母、数字、下划线,且以字母开头' };
}
return null;
}
function validateEmail(value) {
if (typeof value !== 'string') {
return { field: 'email', code: 'TYPE', message: '邮箱必须是字符串' };
}
if (value.length > 254) {
return { field: 'email', code: 'LENGTH', message: '邮箱长度不能超过 254' };
}
if (!EMAIL_RE.test(value)) {
return { field: 'email', code: 'FORMAT', message: '邮箱格式不正确' };
}
return null;
}
function validateAge(value) {
if (!Number.isInteger(value)) {
return { field: 'age', code: 'TYPE', message: '年龄必须是整数' };
}
if (value < 13 || value > 120) {
return { field: 'age', code: 'RANGE', message: '年龄需在 13–120 之间' };
}
return null;
}
function validateRegister(body) {
const errors = [];
const checks = [
validateUsername(body.username),
validateEmail(body.email),
validateAge(body.age),
];
for (const err of checks) {
if (err) errors.push(err);
}
return { valid: errors.length === 0, errors };
}
module.exports = { validateRegister };
接口层调用与响应
// server.js(Express 风格)
const express = require('express');
const { validateRegister } = require('./validators');
const app = express();
app.use(express.json());
app.post('/api/register', (req, res) => {
const result = validateRegister(req.body);
if (!result.valid) {
return res.status(400).json({ ok: false, errors: result.errors });
}
// 校验通过后才进入注册逻辑
return res.status(200).json({ ok: true, message: '注册成功' });
});
app.listen(3000);
验证用例与预期结果
| 请求体 | 预期状态码 | 预期错误 |
|---|---|---|
{"username":"alice_01","email":"alice@example.com","age":25} | 200 | 无 |
{"username":"admin'--","email":"a@b","age":-1} | 400 | username FORMAT、email FORMAT、age RANGE |
{"username":"ab","email":"user@.com","age":"25"} | 400 | username LENGTH、email FORMAT、age TYPE |
{"username":"Bob2024","email":"bob@test.org","age":200} | 400 | age RANGE |
白名单正则的边界处理:用户名正则
^[A-Za-z][A-Za-z0-9_]{2,19}$ 中,首字符单独约束为字母,后续 2–19 个字符允许字母数字下划线,总长度恰好 3–20。使用 ^ 和 $ 锚定整个字符串,避免 test() 在部分匹配时返回 true。邮箱正则不追求 RFC 5322 完全兼容,而是覆盖常见合法格式并拒绝明显非法输入,这是工程上更实用的取舍。
常见绕过与防御:攻击者可能传入
{"username":["admin"]} 这样的数组或对象,typeof 检查会将其拦截。如果使用 Number.isInteger 之外的方式判断年龄,例如 parseInt,则 "25abc" 会被解析为 25 而通过校验,因此必须坚持类型严格判断。另外,校验应在服务端进行,前端校验仅用于提升体验,不能作为安全边界。