Node.js 的 HTTP 参数解析机制与多数语言存在一个关键差异:当同一个参数名在查询字符串或请求体中重复出现时,它不会默认取第一个或最后一个值,而是可能将值转换为数组。这个行为本身不是漏洞,但一旦开发者的校验逻辑只针对字符串设计,攻击者就能利用数组形态绕过检查,制造参数污染。例如你写了 "if (req.query.role === 'admin')",攻击者发送 "?role=user&role=admin",在 Express 中 "req.query.role" 会变成 "['user','admin']",严格相等判断直接失效,权限校验被绕过。

参数污染的核心风险不在于解析器出错,而在于后续代码对参数类型的错误假设。Node.js 生态中 Express、Koa、Fastify、原生 http 模块对重复参数的处理策略并不完全一致,Express 默认使用 qs 库,会将 "a=1&a=2" 解析为数组;Fastify 默认使用 fast-querystring,行为类似但边界情况不同;原生 "URLSearchParams" 则提供 "get()" 和 "getAll()" 两种方法,前者取第一个值,后者返回全部。如果开发者在不同框架之间迁移代码,或者在同一项目里混用解析方式,就很容易出现校验与取值不一致的情况。

最常见的攻击场景:权限与过滤绕过

假设一个管理接口通过查询参数 "role" 判断用户角色,代码写成:

if (req.query.role === 'admin') {
  // 进入管理逻辑
} else {
  res.status(403).end();
}

攻击者请求 "/api/admin?role=user&role=admin"。Express 解析后 "req.query.role" 是数组,与字符串 "'admin'" 比较永远为 false,看起来像是被拒绝。但如果代码逻辑反过来,比如 "if (req.query.role !== 'admin')" 拒绝非管理员,那么数组同样不等于字符串,条件成立,请求被拒绝,这反而是安全的。真正危险的是白名单校验写法,例如:

const allowedRoles = ['user', 'guest'];
if (allowedRoles.includes(req.query.role)) {
  // 放行
}

当 "role" 是数组时,"includes" 对数组与字符串的匹配会失败,结果是不放行。但如果开发者使用 "indexOf" 或某些宽松比较,行为又可能不同。更实际的绕过发生在数字或布尔参数上:"req.query.paid === true" 永远不成立,但如果代码写的是 "if (req.query.paid)",数组 "['false']" 和 "['true']" 的布尔值都是 true,攻击者传 "paid=false&paid=true" 就能让条件判断为真。

对象与原型链层面的污染

当查询字符串使用方括号语法时,qs 库会将其解析为嵌套对象。例如 "?user[name]=test&user[role]=admin" 会得到 "{ user: { name: 'test', role: 'admin' } }"。如果应用后续将这个对象直接用于数据库查询、模板渲染或合并配置,攻击者可以尝试注入 "__proto__"、"constructor"、"prototype" 等特殊键。虽然 qs 库较新版本默认阻止原型污染,但很多项目仍在使用旧版本,或者自行实现了简易解析器。

原型污染一旦发生,影响面会从单个请求扩展到整个应用进程。攻击者通过 "?__proto__[isAdmin]=true" 可能让后续所有对象继承 "isAdmin" 属性,如果应用在权限判断中检查 "user.isAdmin",而普通用户对象没有显式定义该属性,就会从原型链上取到 true。这种攻击不依赖具体业务逻辑,只要存在一个未过滤的深度合并操作,就能造成全局性破坏。

请求体中的参数污染

不仅查询字符串存在重复参数问题,请求体同样如此。当 Content-Type 为 "application/json" 时,JSON 本身不允许重复键,但解析器遇到重复键时的行为并不统一。Node.js 内置的 "JSON.parse" 取最后一个值,而某些流式解析器可能取第一个值或抛出异常。如果应用使用多个解析层,例如先经过 API 网关再到达 Node 服务,网关与后端对重复键的处理差异就可能导致校验绕过。

更隐蔽的是 "application/x-www-form-urlencoded" 与 "multipart/form-data" 的混用。攻击者可以同时发送查询字符串参数和请求体参数,如果应用在某个中间件中从查询字符串取值做校验,而业务逻辑从请求体取值,两个来源的参数名相同但值不同,就会形成校验与使用不一致。例如校验层检查 "req.query.userId === '123'",业务层使用 "req.body.userId" 进行数据库查询,攻击者只需让查询字符串通过校验,请求体携带任意目标 ID。

数组参数导致的类型混淆

很多 Node.js 应用在接收到参数后会将其传递给数据库查询、文件路径拼接或外部 API 调用。当参数是数组时,这些操作的行为可能与预期完全不同。例如 MongoDB 查询中,如果 "req.query.id" 是数组,"find({ _id: req.query.id })" 会变成 "$in" 查询,返回所有匹配的文档,而不是单个文档。如果这是一个删除接口,原本只能删除一个 ID,现在可以批量删除。

在文件操作中,如果路径拼接使用了数组参数,Node.js 的 "path.join" 会将数组转换为逗号分隔的字符串,或者直接抛出 TypeError。攻击者可以利用这种异常行为探测应用内部的参数处理逻辑,进而构造更精确的攻击载荷。在 SQL 查询中,如果参数数组被直接拼接到 SQL 语句,可能产生语法错误,也可能因为数组的 "toString()" 方法产生意外的查询条件。

防御策略:从类型校验入手

最有效的防御不是过滤重复参数,而是在使用参数之前强制校验其类型。对于期望字符串的参数,明确检查 "typeof req.query.role === 'string'",如果类型不符则拒绝请求。对于期望数字的参数,使用 "Number.isInteger(Number(req.query.id))" 并且不允许数组通过。这种校验应该放在路由处理的最前面,作为统一中间件执行。

function enforceStringParam(value) {
  if (typeof value !== 'string') {
    throw new Error('Invalid parameter type');
  }
  return value;
}

app.get('/api/user', (req, res) => {
  const userId = enforceStringParam(req.query.userId);
  // 后续业务逻辑
});

对于必须支持数组的参数,例如多选框提交的 "tags",应该显式声明该参数允许数组,并校验数组中每个元素的类型。不要依赖框架的默认解析行为,而是在应用层明确参数契约。可以使用 JSON Schema 或 Joi 等校验库,在请求进入业务逻辑之前完成类型、格式和范围的全面检查。

统一参数获取方式

很多参数污染漏洞的根源是应用在不同位置使用不同的方法获取同名参数。有的地方用 "req.query",有的地方用 "req.params",有的地方用 "req.body",还有的地方用 "req.param()" 这种已废弃的方法。Express 3 时代的 "req.param()" 会依次从 "params"、"body"、"query" 中查找参数,这种模糊的查找顺序本身就容易产生混淆。现代 Express 版本已经移除了该方法,但很多旧项目仍在使用。

建议在项目中建立统一的参数访问层,例如定义一个 "getParam(req, name)" 函数,明确从哪个来源取值,以及如何处理重复参数。如果业务需要同时支持查询字符串和请求体,应该在函数内部做来源优先级排序,并确保校验逻辑与取值逻辑使用同一个函数。不要出现校验时从 "req.query" 取值、业务时从 "req.body" 取值的情况。

处理原型污染风险

针对原型污染,首先要升级依赖库。qs 库在 6.7.0 版本之后默认阻止 "__proto__" 的解析,如果你的项目还在使用旧版本,应该尽快升级。其次,对于需要深度合并对象的操作,使用安全的合并函数,例如 Lodash 的 "merge" 在较新版本中已经修复了原型污染问题,但 "mergeWith" 和自定义合并逻辑仍可能引入风险。最稳妥的做法是在合并之前对对象的键进行白名单过滤,只允许已知的键名进入合并流程。

如果应用需要处理用户提供的嵌套对象,建议不要直接使用 qs 的深度解析功能,而是将参数扁平化处理。例如将 "user[name]" 解析为独立的 "user_name" 参数,避免生成嵌套对象。这样既减少了原型污染的攻击面,也简化了后续的参数校验逻辑。对于必须使用嵌套结构的场景,在解析后立即使用 "Object.create(null)" 创建无原型对象,并递归检查每个键名是否包含 "__proto__"、"constructor"、"prototype" 等危险字符串。

监控与日志中的参数记录

参数污染攻击的载荷通常包含重复参数名,这种模式在正常业务请求中很少出现。可以在日志系统中记录请求的原始查询字符串和请求体,当发现同一参数名出现多次时触发告警。同时,在应用层对重复参数进行计数,如果某个参数名在单个请求中出现的次数超过业务预期,例如 "userId" 出现两次,就应该记录详细日志并考虑拒绝请求。

需要注意的是,日志记录本身也可能被参数污染利用。如果日志系统将参数直接写入文件或数据库,而参数值中包含换行符或特殊字符,攻击者可以伪造日志条目。在记录参数之前应该对值进行转义或截断,避免日志注入。对于数组类型的参数,记录其 JSON 序列化结果而不是直接拼接字符串。

框架层面的配置加固

Express 允许通过 "query parser" 配置项修改查询字符串解析行为。如果业务不需要复杂的嵌套对象解析,可以将解析器设置为 "'simple'",此时 Express 使用 Node.js 内置的 "querystring" 模块,重复参数会取第一个值,不会产生数组或嵌套对象。这能从根本上消除一大类参数污染风险,但代价是失去了对复杂查询参数的支持。对于大多数 RESTful API,简单解析已经足够。

app.set('query parser', 'simple');

Fastify 提供了更细粒度的控制,可以通过 "querystringParser" 选项自定义解析函数。如果使用自定义解析器,应该明确处理重复参数的情况,例如取第一个值、取最后一个值或抛出异常。无论选择哪种策略,都要保证整个应用中的行为一致,并且文档化这个策略,让所有开发者知晓。

参数污染漏洞的修复往往不是单一代码改动就能完成的,它需要开发团队在参数校验、类型处理、依赖管理和日志监控等多个层面建立一致的规范。攻击者利用的通常是应用对参数类型的错误假设,而不是解析器本身的缺陷。只要在参数进入业务逻辑之前强制进行类型校验,并统一参数的获取与使用方式,绝大多数参数污染攻击都能被有效阻断。