1. 输入验证

输入验证 学习卡片

为什么注册接口是输入验证的第一道关口

用户注册接口直接接收外部输入,且这些输入会进入数据库、日志、邮件模板等多个下游环节。如果不在入口处做严格校验,攻击者可以通过用户名注入特殊字符、通过邮箱字段夹带超长载荷、通过年龄字段传入负数或超大值来探测系统边界。本节采用白名单校验策略:只允许明确合法的字符和取值范围通过,其余一律拒绝。

白名单校验的核心原则是"默认拒绝"——先定义合法格式,再判断输入是否完全匹配。与之相对的黑名单(禁止特定字符)容易被编码绕过,不适合作为主要防线。

三个字段的校验规则设计

字段类型约束白名单规则拒绝示例
用户名字符串长度 3–20,仅允许字母、数字、下划线,且必须以字母开头admin'--、_user、ab
邮箱字符串长度 ≤ 254,匹配标准邮箱格式,本地部分仅允许字母数字和 . _ % + -a@b、user@.com、含空格的地址
年龄整数13 ≤ age ≤ 120,必须是整数,不接受字符串形式的数字-1、0、200、"25"

校验流程与错误聚合

校验不应在第一个错误处就返回,而应收集所有字段的错误后一次性返回结构化信息,方便前端逐项提示。每个错误包含字段名、错误码和可读消息。

注册请求体 username: string email: string / age: int 白名单校验器 类型检查 → 正则匹配 → 范围判断 收集所有字段错误 全部通过? errors.length === 0 200 OK 进入注册流程 400 Bad Request { errors: [ {field, code, message} ] } 不进入注册流程 用户名校验 类型:typeof === "string" 长度:3 ≤ len ≤ 20 正则:/^[A-Za-z][A-Za-z0-9_]{2,19}$/ 首字符:必须为字母 拒绝:admin'--、_user、ab 通过:alice_01、Bob2024 邮箱校验 类型:typeof === "string" 长度:≤ 254 正则:/^[A-Za-z0-9._%+-]+@ [A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ 拒绝:a@b、user@.com、含空格 通过:alice@example.com 年龄校验 类型:Number.isInteger(age) 范围:13 ≤ age ≤ 120 不接受字符串 "25" 不接受小数 25.5 拒绝:-1、0、200、"25" 通过:18、25、99

关键实现代码

以下实现使用 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}400username FORMAT、email FORMAT、age RANGE
{"username":"ab","email":"user@.com","age":"25"}400username LENGTH、email FORMAT、age TYPE
{"username":"Bob2024","email":"bob@test.org","age":200}400age 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 而通过校验,因此必须坚持类型严格判断。另外,校验应在服务端进行,前端校验仅用于提升体验,不能作为安全边界。