直接说问题,Sails.js 的 Waterline ORM 在参数化查询层面做得相当规范,但真正的注入风险往往不在 ORM 生成的查询本身,而在于开发者绕过 ORM 使用原生查询、在查询条件中构建动态键名,以及 MongoDB 等 NoSQL 数据库特有的操作符注入。把防注入等同于“用了 ORM 就安全”是最大的误区。
原生查询是最大的突破口Waterline 提供了 .sendNativeQuery() 和 .query() 方法,允许直接执行 SQL 或 MongoDB 查询字符串。一旦业务逻辑需要复杂报表、跨表聚合或者数据库专有函数,开发者很容易写出拼接字符串的代码。比如下面这种写法,攻击者通过 req.query.sortOrder 传入 "id ASC; DROP TABLE users;" 就能完成一次经典注入。
// 危险写法 const sortOrder = req.query.sortOrder || 'id ASC'; const rawQuery = 'SELECT * FROM user ORDER BY ' + sortOrder; const results = await sails.sendNativeQuery(rawQuery);
正确做法是把原生查询也参数化。Waterline 对 SQL 适配器支持 $1、$2 这样的占位符,对 MongoDB 适配器则要求传递数组形式的参数。排序字段这类无法参数化的部分,必须用白名单校验。
// 安全写法:参数化值 + 白名单校验动态列名 const ALLOWED_SORT_COLUMNS = ['id', 'username', 'email', 'createdAt']; const ALLOWED_DIRECTIONS = ['ASC', 'DESC']; const sortColumn = ALLOWED_SORT_COLUMNS.includes(req.query.sortBy) ? req.query.sortBy : 'id'; const sortDirection = ALLOWED_DIRECTIONS.includes(req.query.sortOrder?.toUpperCase()) ? req.query.sortOrder.toUpperCase() : 'ASC'; const rawQuery = 'SELECT * FROM user ORDER BY ' + sortColumn + ' ' + sortDirection + ' LIMIT $1 OFFSET $2'; const results = await sails.sendNativeQuery(rawQuery, [limit, offset]);
这里的关键原则是:任何来自用户输入的值必须通过占位符传递,任何来自用户输入的列名、表名、操作符必须经过严格的白名单校验,绝不能依赖黑名单过滤。
MongoDB 操作符注入,Waterline 的盲区当 Sails 使用 MongoDB 适配器时,Waterline 的查询语法底层会被转换成 MongoDB 的查询对象。问题在于,如果开发者直接将前端传来的 JSON 查询条件合并到 .find() 的 criteria 中,攻击者可以注入 $where、$regex、$ne 等操作符来绕过认证或窃取数据。
假设登录接口接收一个 JSON 对象作为查询条件,攻击者发送的请求体是 {"username": "admin", "password": {"$ne": ""}}。这个查询会被 Waterline 原样传递给 MongoDB,语义变成“查找用户名为 admin 且密码不等于空字符串的用户”,直接绕过密码校验。
// 危险写法:直接将 req.body 作为查询条件 const user = await User.findOne(req.body);
修复方案不是简单地过滤 $ 开头的键,因为嵌套对象中同样可以注入。最彻底的办法是在将用户输入传入 Waterline 查询前,对对象做递归的键名清洗,剥离所有包含 $ 前缀的键。同时,对于登录这类关键操作,永远不要直接使用用户输入的对象作为查询条件,而是显式提取需要的字段。
// 安全写法:显式提取字段,并对值做类型校验
const { username, password } = req.body;
if (typeof username !== 'string' || typeof password !== 'string') {
return res.badRequest('Invalid input');
}
const user = await User.findOne({ username, password });
更进一步,可以编写一个递归清洗函数,作为全局中间件或工具函数,对所有进入 Waterline 查询的对象做净化。
function sanitizeCriteria(obj) {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map(sanitizeCriteria);
const cleaned = {};
for (const key of Object.keys(obj)) {
if (key.startsWith('$')) continue; // 丢弃操作符键
cleaned[key] = sanitizeCriteria(obj[key]);
}
return cleaned;
}
// 在查询前使用
const rawCriteria = req.body.filter;
const safeCriteria = sanitizeCriteria(rawCriteria);
const users = await User.find(safeCriteria);
这个清洗函数会递归遍历对象的所有层级,移除任何以 $ 开头的键,从而杜绝操作符注入。但需要注意,如果业务确实需要用到 Waterline 的内置操作符如 contains、startsWith,应该在服务端显式构建这些条件,而不是让用户直接传递操作符。
动态属性名的注入风险Waterline 允许通过 .find() 的 criteria 对象动态指定查询字段,如果字段名来自用户输入且未经校验,攻击者可以利用这一点访问敏感字段,甚至通过精心构造的字段名触发数据库层面的异常,暴露底层结构信息。
比如一个接口允许用户选择按哪个字段排序,如果直接把用户输入作为排序字段名传入,攻击者可以遍历常见敏感字段名,通过返回结果的时间差异或错误信息来判断字段是否存在,这是一种盲注变种。
// 危险:动态字段名直接来自用户输入 const sortField = req.query.sortField; const records = await Record.find().sort(sortField + ' ASC');
修复方式依然是白名单映射。将所有允许用户操作的字段名维护在一个配置对象中,用户传入的只是别名或标识,服务端将其映射为真实的数据库字段名。
const FIELD_MAP = {
'name': 'fullName',
'date': 'createdAt',
'status': 'orderStatus'
};
const requestedField = FIELD_MAP[req.query.sortField] || 'createdAt';
const records = await Record.find().sort(requestedField + ' ASC');
这样做不仅防止了注入,也隔离了前端与数据库结构,降低了耦合度。
Waterline 的 .meta() 与查询选项注入Waterline 的 .meta() 方法用于传递适配器级别的选项,比如 PostgreSQL 的 schema、MongoDB 的 readPreference。如果这些选项的值来自用户输入且未经校验,同样可能被利用。虽然这不会直接导致数据泄露,但攻击者可能通过指定不存在的 schema 触发错误,或者通过操纵读取偏好来影响查询行为,造成拒绝服务或信息推断。
所有通过 .meta() 传递的参数,只要其值来自外部,就必须进行白名单校验。例如,如果允许用户选择数据源区域,应该预先定义好可用的区域列表,而不是直接把用户输入传给 .meta({ region: userInput })。
关联查询中的隐式注入Waterline 的 .populate() 方法用于关联查询,其底层会生成额外的查询语句。如果 populate 的关联名称或过滤条件来自用户输入,同样存在注入风险。比如一个接口允许用户指定要关联加载的模型,如果直接使用用户输入的字符串作为 populate 的参数,攻击者可以尝试加载不应该暴露的关联模型。
// 危险:关联名称来自用户输入
const populateField = req.query.expand;
const order = await Order.findOne({ id: orderId }).populate(populateField);
正确做法是维护一个允许 populate 的关联白名单,只允许用户从预定义的关联中选择。
const ALLOWED_POPULATES = ['customer', 'items', 'items.product'];
const requestedPopulate = ALLOWED_POPULATES.includes(req.query.expand) ? req.query.expand : null;
let query = Order.findOne({ id: orderId });
if (requestedPopulate) {
query = query.populate(requestedPopulate);
}
const order = await query;
错误信息泄露与防御分层
即使查询本身是安全的,数据库返回的错误信息也可能被攻击者利用。Waterline 默认会将底层数据库的错误包装后抛出,如果全局错误处理中间件不加区分地将这些错误返回给客户端,攻击者可以通过构造恶意输入来触发特定错误,从而推断出表结构、字段类型甚至部分数据。
在生产环境中,应该对 Waterline 抛出的错误做分类处理。对于查询语法错误、字段不存在、类型不匹配等数据库层面的错误,只记录到日志系统,返回给客户端的是通用的“请求参数有误”或“服务器内部错误”信息,绝不暴露原始错误消息。
// 全局错误处理中间件
sails.config.globals.customErrorHandler = function(err, req, res, next) {
if (err.code === 'E_MISSING_ATTR' || err.code === 'E_INVALID_TYPE' || err.originalError) {
sails.log.error('Database error:', err);
return res.serverError('An unexpected error occurred');
}
return res.serverError(err.message);
};
输入验证是第一道防线
防注入不应该只依赖 ORM 层的净化,最坚固的防线是在数据进入业务逻辑之前就完成校验。Sails.js 自带了基于 anchor 的模型验证,但模型验证主要关注数据类型和业务规则,对于注入防护的覆盖并不完整。
建议在 Controller 层使用专门的验证库,比如 validator.js 或 joi,对所有来自外部的输入做严格的类型、格式和范围校验。例如,对于分页参数 limit 和 offset,不仅要校验它们是数字,还要限制其最大值,防止攻击者通过传入超大数值造成性能问题。
const Joi = require('joi');
const querySchema = Joi.object({
page: Joi.number().integer().min(1).max(100).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sortBy: Joi.string().valid('id', 'username', 'createdAt').default('id'),
order: Joi.string().valid('ASC', 'DESC').default('ASC')
});
const { error, value } = querySchema.validate(req.query);
if (error) {
return res.badRequest('Invalid query parameters');
}
这种分层防御策略将注入风险降到最低:输入验证拦截格式异常的请求,白名单校验限制动态字段和操作符,参数化查询消除 SQL 拼接,递归清洗对象防止 NoSQL 操作符注入,错误信息脱敏阻断信息泄露。每一层都独立发挥作用,即使某一层被绕过,后续防线仍然有效。
适配器层面的安全配置不同的数据库适配器有各自的安全配置项。对于 MySQL/PostgreSQL 适配器,应该确保数据库连接用户只拥有应用所需的最小权限,比如只授予 SELECT、INSERT、UPDATE、DELETE 权限,不授予 DROP、ALTER 等 DDL 权限。这样即使发生注入,攻击者的操作范围也受到限制。
对于 MongoDB 适配器,应该在数据库层面禁用服务端 JavaScript 执行,因为 $where 操作符依赖 JavaScript 引擎,禁用后即使操作符注入成功也无法执行任意代码。在 MongoDB 配置中设置 javascriptEnabled: false 即可。
Waterline 本身也提供了一些安全相关的配置。在 config/datastores.js 中,可以为每个数据源设置 connectionLimit,防止连接池被恶意请求耗尽。同时,启用 queryTimeout 可以防止攻击者通过构造超长执行时间的查询来拖垮数据库。
// config/datastores.js
module.exports.datastores = {
default: {
adapter: 'sails-mysql',
url: 'mysql://user:password@localhost:3306/db',
pool: {
min: 2,
max: 10
},
queryTimeout: 30000 // 30秒超时
}
};
综合来看,Sails.js Waterline 的防注入策略是一个多层次的体系。ORM 提供的参数化查询是基础,但开发者必须清醒地认识到它的边界:它保护的是查询中的值,而不是查询的结构。任何影响查询结构的用户输入——列名、表名、操作符、关联名称、排序方向——都必须通过白名单严格控制。同时,输入验证、错误处理、数据库权限和配置加固共同构成了完整的防御链条。安全不是某个工具或框架的特性,而是贯穿开发全过程的意识和实践。
