首页 / 文章

使用 express-validator Chains 在 Express 中实现服务器端输入验证

了解为何 Express API 必须在服务器端验证输入,express-validator 链如何为 validationResult 记录错误,以及如何构建生产环境下的路由。

发布者:REACTAPP.TOP阅读约 10 分钟2373 词

来源: Stop Trusting User Input: A Beginner’s Guide to `express-validator` in Express.js

迟早,每一个接收输入的 Express API 都会收到类似 "age": "banana" 的数据,服务器要么崩溃,要么更糟糕的是将其存储下来。验证机制正是用来防止这种情况发生的,无论前端表单多么严谨,做好后端验证都至关重要。本指南将解释什么是验证以及为何必须由服务器来承担这一职责,展示手动验证的方法及其局限性,随后会详细说明 express-validator 的内部工作原理、最常用的方法,以及如何在一个实际的路由中结合验证、数据净化和基于数据库的检查。

验证的含义

验证是指在代码对数据进行处理之前,确认传入数据的格式和内容是否符合预期。一个恰当的类比是场所入口处的身份检查:无论某人看起来多么自信,工作人员都会核对其身份证件,若不符合要求则无法进入。验证会对请求中的每个字段进行同样的严格检查——这真的是电子邮件地址吗?这确实是数字吗?该字段是否存在?

以注册接口为例,一个格式正确的请求体可能如下所示:

{
  "name": "Rahul Sharma",
  "email": "rahul@example.com",
  "age": 25,
  "password": "supersecret123"
}

对其进行验证意味着要确认email的格式符合电子邮件地址的标准,age是数字而非文本,且password的长度足够长,而不仅仅是走形式。

为何后端不能依赖前端检查

一个常见的反对意见是,注册表单已经将某些字段标记为required,并且会在浏览器中验证电子邮件格式。但这还不够,因为从攻击者的角度来看,前端验证是可选的。任何人都可以使用Postman、curl或脚本直接调用您的API,从而绕过界面中的所有规则。如果服务器不自行验证数据,那就等于信任了任何发送请求的人。

服务器端验证的具体优势:

  • 它能保护您的数据。当某个字段要求输入数字,却收到了"twenty-five"这样的内容时,要么会出现错误,更糟糕的是会产生格式错误的记录,日后引发各种故障。
  • 它能缩小攻击面。未经验证的输入是SQL注入和跨站脚本(XSS)等注入攻击的常见入口。虽然验证与净化措施能降低此类风险,但它们只能补充参数化查询和正确的输出编码,无法替代二者。
  • 它能避免令人困惑的故障。许多生产环境中的崩溃问题,其实都是由于在实际错误出现前几行使用了缺失、为空或格式错误的字段所导致的。
  • 它能生成有用的错误信息。客户端不会收到笼统的“出错了”提示,而是能明确得知需要修复什么问题,例如“电子邮件格式无效”或“密码长度至少为8个字符”。
  • 分工很简单:前端验证是为了帮助用户,而后端验证则是为了保护应用程序。两者都很重要,但只有后端验证是必须的。

    手动验证及其阻碍扩展的原因

    首先看看手动实现的版本。这是从解析后的请求体中提取字段的注册路由开头部分(这里假设已注册了如 express.json() 这样的 JSON 请求体解析器):

    app.post("/register", (req, res) => {
      const { name, email, age, password } = req.body;
    

    每个字段都有对应的校验逻辑,一旦出错就会返回 400 响应:

      if (!name || typeof name !== "string" || name.trim().length === 0) {
        return res.status(400).json({ error: "Name is required" });
      }  if (!email || !email.includes("@")) {
        return res.status(400).json({ error: "A valid email is required" });
      }  if (!age || typeof age !== "number" || age < 18) {
        return res.status(400).json({ error: "Age must be a number and at least 18" });
      }  if (!password || password.length < 8) {
        return res.status(400).json({ error: "Password must be at least 8 characters" });
      }  // If we made it here, the data is safe to use
      res.json({ message: "User registered successfully" });
    });
    

    对于小型应用来说,这种方式完全可行。但随着代码库的扩大,问题会逐渐累积:

    • 每个需要接收输入的路由都需要独立的 if 语句块。
    • 各种校验逻辑会在不同路由之间重复编写,对其中一个版本的修改很容易在其他版本中被忽略。
  • 真正的业务逻辑,即用户注册功能,被隐藏在验证代码之下。
  • 手动处理各种边界情况,比如空字符串、null值、错误类型以及长度过短或过长的值,很快就会变得十分繁琐。
  • 手动实现的方式还存在一些细微的缺陷。email.includes("@")会接受许多无效的邮箱地址,而对非字符串类型调用.includes方法则会抛出错误。这类重复且容易出错的检查工作正是库应该处理的。

    express-validator 提供的功能

    express-validator是一组基于validator.js库构建的Express中间件。无需为每个字段编写条件逻辑,只需声明规则,该库便会根据这些规则对请求数据进行检查。首先需要导入两个主要函数:

    const { body, validationResult } = require("express-validator");
    

    使用声明式规则重写的相同注册路由如下所示:

    app.post(
      "/register",
      [
        body("name").trim().notEmpty().withMessage("Name is required"),
        body("email").isEmail().withMessage("A valid email is required"),
        body("age").isInt({ min: 18 }).withMessage("Age must be at least 18"),
        body("password").isLength({ min: 8 }).withMessage("Password must be at least 8 characters"),
      ],
      (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
          return res.status(400).json({ errors: errors.array() });
        }    res.json({ message: "User registered successfully" });
      }
    );
    

    这些规则现在用近乎通俗的语言表述:去除空格后名称不能为空,邮箱必须是有效的邮箱地址,年龄必须是至少18的整数,密码长度必须至少为8个字符。最后的处理函数只负责判断一件事:是否有任何错误发生?

    当请求到达时会发生什么

    初学者最常见的误解是认为express-validator会自动拒绝无效请求。实际上它并不会这样做,而是收集有关错误的信息,将响应的处理完全交由你决定。每个请求的处理流程如下:

    • Express会启动为该路由定义的中间件链,并按顺序执行其中的每个中间件。
  • 每个验证链均作为中间件运行。当执行 body("email").isEmail() 时,它会从 req.body 中读取 email 并对其进行验证。验证失败不会抛出异常或终止请求;该验证链会将错误记录在请求对象上,然后继续执行后续操作。
  • 每条规则都会被依次执行。由于失败的验证链仍会调用下一个中间件,因此所有规则都会被运行,所有的问题都会被记录下来,而不仅仅是第一个出现的问题。
  • validationResult(req) 用于读取已记录的错误。它只是一个普通函数,而非中间件,因此需要在处理程序中调用它。它本身不会进行任何验证操作,只是反馈各验证链已经收集到的错误信息。
  • 由你的代码决定最终结果。如果 errors.isEmpty() 返回 false,则需返回包含详细信息的 400 状态码;否则即可继续执行实际任务。
  • 这种设计更注重灵活性而非便捷性。由于不会自动拒绝请求,你可以控制响应格式和时机,并能在检查与最终决策之间添加其他逻辑。相应的代价是,若忘记调用 validationResult(),该路由的验证功能就会悄然失效,这也是许多团队会将相关检查封装在可重用的小型中间件中的原因。

    四个核心组成部分

    了解了这些构成要素后,这个库的使用就相当简单了。

    验证链

    body()、query()、param() 和 check() 等函数构成了一条链:一系列用点号连接的规则,例如 body("email").trim().isEmail()。每个函数针对请求的不同部分:

    • body() 用于读取 req.body,即 JSON 或表单数据。
    • query() 用于读取 req.query,即 ? 后面的查询字符串,如 ?page=2。
    • param() 用于读取 req.params,即路由参数,如 /users/:id 中的 :id。
    • check() 会同时检查请求的所有部分。

    验证器

    验证器是用于检测值的函数:isEmail()、isInt()、isLength()、isAlphanumeric()等等。大多数验证器都会调用validator.js,该文件已包含针对常见格式的成熟且经过充分测试的逻辑。

    净化器

    净化器不仅会检查数据,还会对数据进行转换。trim()用于去除周围的空白字符,normalizeEmail()则将邮箱地址转换为标准格式,而escape()则会用实体编码替换那些在HTML中具有特殊意义的字符,从而防止它们被当作标记语言解析。净化器可以与验证器在同一处理链中一起使用,且顺序很重要:如果将trim()放在notEmpty()之前,那么仅包含空格的值就会被视为无效。

    每次请求的结果存储

    每当某个验证链被执行时,其检测到的错误都会关联到特定的 req 对象。validationResult(req) 只需从该存储中读取数据,因此它需要与验证链所处理的请求对象相同的对象。

    整体流程如下:请求到达后,验证链开始运行并默默记录问题,处理程序随后获取结果,最后由你决定响应方式。如果你正在考虑这种方案与基于模式的验证方式,我们关于 Zod 和 express-validator 的对比文章详细分析了二者之间的权衡。

    适用于大多数实际项目的验证方法

    你并不需要完整的 API,只需一小部分功能就能处理大多数情况。

    位置选择器

    • body(field):用于请求正文中的字段
    • query(field):用于查询字符串参数
  • param(field) 用于路由参数
  • 常用验证器

    • .notEmpty():值不能为空
    • .isEmail():值必须是有效的电子邮件地址
    • .isInt({ min, max }):值必须是整数,可选地需在指定范围内
    • .isLength({ min, max }):检查字符串的长度
    • .isString():值必须是字符串类型
    • .isBoolean():值必须是布尔值
    • .matches(regex):值必须符合自定义模式
    • .isIn([...values]):值必须是预定义集合中的某个值,非常适合用于只能是"active"或"inactive"这样的status字段

    常用净化器

    • .trim():去除字符串开头和结尾的空白字符
    • .escape():对HTML字符进行转义,降低注入风险
    • .normalizeEmail():标准化电子邮件格式
    • .toInt()和.toFloat():将字符串输入转换为数字

    自定义规则

    • .custom((value, { req }) => { ... }):当内置验证规则不适用时实现自定义规则,例如确认用户名未被注册

    错误报告

    • .withMessage("text"):为前面的规则设置提示信息
    • validationResult(req):收集当前请求的所有错误信息
    • errors.array():将这些错误转换为适合JSON响应的普通数组

    生产环境风格的路由

    以下示例更接近真实 API 的结构:一个用于创建博客文章的路由,其中包含了验证、数据净化以及数据库查询操作。首先是导入部分,这里的 Post 代表类似 Mongoose 模型这样的数据结构:

    const express = require("express");
    const { body, validationResult } = require("express-validator");
    const Post = require("./models/Post"); // a Mongoose model, for example
    

    接着是路由器以及包含规则数组的路由定义:

    const router = express.Router();router.post(
      "/posts",
      [
        body("title")
          .trim()
          .notEmpty().withMessage("Title is required")
          .isLength({ max: 100 }).withMessage("Title cannot exceed 100 characters"),    body("slug")
          .trim()
          .notEmpty().withMessage("Slug is required")
          .matches(/^[a-z0-9-]+$/).withMessage("Slug can only contain lowercase letters, numbers, and hyphens")
          .custom(async (slug) => {
            const existing = await Post.findOne({ slug });
            if (existing) {
              throw new Error("This slug is already taken");
            }
            return true;
          }),    body("content")
          .trim()
          .notEmpty().withMessage("Content cannot be empty")
          .isLength({ min: 20 }).withMessage("Content should be at least 20 characters"),    body("tags")
          .optional()
          .isArray().withMessage("Tags must be an array")
          .custom((tags) => tags.every((t) => typeof t === "string"))
          .withMessage("Each tag must be a string"),
      ],
      async (req, res) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
          return res.status(400).json({ errors: errors.array() });
        }    try {
          const post = await Post.create(req.body);
          res.status(201).json(post);
        } catch (err) {
          res.status(500).json({ error: "Something went wrong while saving the post" });
        }
      }
    );module.exports = router;
    

    有几点细节值得研究:

    • 基于数据库的规则验证。 slug 字段使用异步的 .custom() 验证器来查询已存在的文章。正则表达式可以确认 slug 的格式,但只有通过数据库查询才能确定其唯一性。在自定义函数中抛出异常即可将该字段标记为无效,并附带相应错误信息。
  • 仍被检查的可选字段。 tags 被标记为 .optional(),因此没有标签的帖子也能通过验证,但若提供了标签,则必须是字符串数组形式。
  • 专注的处理器。 路由处理器负责两项工作:报告验证错误以及保存帖子。所有关于“这些数据是否可接受”的逻辑都存在于规则数组中,这样便于阅读和修改。
  • 在复制此模式之前需注意两点。首先,验证器中的唯一性检查可能存在竞态条件:两个具有相同标识符的请求都可能在数据被保存之前通过查询,因此需在对应列上维护唯一索引,并处理重复键错误。其次,直接将req.body传递给Post.create()会保存客户端发送的所有字段,而不仅仅是经过验证的那些。express-validator提供了matchedData(req)方法,仅返回你的验证链所覆盖的字段,使用该方法即可解决这种批量赋值的问题。

    随着应用程序的发展,应将规则数组移至专用模块中,例如validators/postValidator.js,然后再将其导入到路由文件中,这样就能避免路由文件被大量验证代码占据。

    总结

    验证并非可选的附加功能,它决定了应用程序能否信任其处理的数据。对于少量字段而言,手写的if条件检查尚可,但一旦应用于众多路由,效率就会迅速下降。

    express-validator并非魔法工具。它会以中间件的形式执行你定义的规则,将验证失败的情况记录在请求中,并通过validationResult()函数提供报告,让你据此决定响应方式。基于这样的工作流程,该库的其余部分则是一系列验证器和净化器工具,你可以根据需要选用。实际应用中最重要的几点是:

    • 无论前端是否有验证,都应将后端验证视为必须。
    • 务必调用validationResult(),最好通过一个通用的中间件来调用,因为该库本身不会主动拒绝请求。
    • 在处理流程中要有意识地安排净化器的执行顺序。
  • 利用数据库约束实现回退唯一性验证。
  • 保存matchedData(req)而非原始请求体。