在Node.js开发中,直接拼接SQL语句是极其危险的,它会让你的应用门户大开,面临SQL注入攻击。防止SQL注入最有效的方法就是使用参数化查询,而ORM(对象关系映射)工具如Sequelize、TypeORM或Prisma,都内置了这项关键安全功能。但很多开发者在使用ORM执行原始查询时,常常忽略或错误地使用参数化,导致安全漏洞。核心要点是:无论你使用哪种ORM,执行原始SQL时,永远不要将用户输入直接嵌入查询字符串,而必须使用占位符和参数绑定。例如,在Sequelize中,你应该使用

sequelize.query('SELECT * FROM users WHERE email = ?', { replacements: [userEmail], type: QueryTypes.SELECT })

,而不是

"SELECT * FROM users WHERE email = '${userEmail}'"

。这篇文章将详细拆解如何在主流Node.js ORM中正确、安全地执行参数化原始查询。

理解SQL注入与参数化查询的原理

SQL注入的原理是攻击者通过应用程序的输入点,插入恶意的SQL代码片段。当这些输入被不加处理地拼接到SQL语句中并执行时,攻击者就能窃取、篡改或删除数据库数据。例如,一个登录查询

"SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"

,如果用户在用户名输入

' OR '1'='1

,整个查询的逻辑就会被改变。参数化查询是根除此问题的银弹。它的工作原理是将SQL语句的结构(命令和列名)与数据值(用户输入)分开处理。数据库引擎会先将带占位符的SQL语句编译为执行计划,随后传入的参数值会被严格视为数据,而不会被解析为SQL代码。这就从根本上切断了注入的可能性。

Sequelize中的参数化原始查询实践

Sequelize提供了

sequelize.query()

方法来执行原始SQL。安全的关键在于正确使用"replacements"或"bind"参数。对于未命名的占位符(如"?"),使用"replacements"数组:

const result = await sequelize.query(
    'SELECT * FROM products WHERE category = ? AND price > ?',
    {
        replacements: ['electronics', 100],
        type: QueryTypes.SELECT
    }
);

对于命名的占位符(如":name"),则使用"replacements"对象:

const result = await sequelize.query(
    'SELECT * FROM products WHERE category = :cat AND price > :price',
    {
        replacements: { cat: 'electronics', price: 100 },
        type: QueryTypes.SELECT
    }
);

请注意,"replacements"会对传入值进行适当的转义和引用。对于"LIKE"子句等复杂情况,你仍需小心:通配符"%"和"_"应包含在参数值中,而不是查询字符串里:

replacements: { searchTerm: '%' + userInput + '%' }

TypeORM中的参数化原始查询策略

在TypeORM中,你可以使用"EntityManager"或"Connection"对象的"query"方法。参数化通过占位符实现,例如"?"(支持数字索引"?1"、"?2")或命名参数":name"。使用数字索引占位符的示例:

await dataSource.query(
    'SELECT * FROM posts WHERE author = ?1 AND status = ?2',
    [authorId, 'published']
);

使用命名参数的示例:

await dataSource.query(
    'SELECT * FROM posts WHERE author = :authorId AND status = :status',
    { authorId, status: 'published' }
);

TypeORM会将这些参数安全地传递给底层的数据库驱动(如"node-postgres"或"mysql2"),由驱动完成最终的参数化处理。这确保了即使执行原始查询,也能享受与ORM高级API同等级别的安全防护。

Prisma:$queryRaw与$executeRaw的安全使用

Prisma Client提供了

$queryRaw

$executeRaw

来执行原始SQL。它强制使用参数化查询来保障安全,这是其设计哲学的一部分。对于模板字符串字面量,你必须使用"Prisma.sql"辅助函数和"?"占位符:

const result = await prisma.$queryRaw(
    Prisma.sql"SELECT * FROM User WHERE email = ${email}"
);

Prisma会安全地将"${email}"的值作为参数处理。另一种方式是使用

$queryRawUnsafe

,但强烈不推荐,因为它绕过了安全校验,仅应在你绝对信任SQL来源时使用。对于批量插入或更新,使用

$executeRaw

await prisma.$executeRaw(
    Prisma.sql"UPDATE Product SET stock = stock - 1 WHERE id = ${productId}"
);

始终记住,Prisma的"?"占位符语法因底层数据库(PostgreSQL、MySQL等)而异,但"Prisma.sql"模板会为你处理这些差异。

Knex.js查询构建器的参数化机制

虽然Knex.js严格来说是一个查询构建器而非全功能ORM,但它被广泛使用且同样面临注入风险。Knex的"raw"方法支持参数化。安全的方式是将值作为额外的参数传入:

knex.raw('SELECT * FROM accounts WHERE id = ?', [accountId])

对于多个参数:

knex.raw('UPDATE accounts SET balance = ? WHERE id = ?', [newBalance, accountId])

Knex会将查询转换为数据库驱动的参数化查询。绝对要避免使用字符串拼接:

knex.raw("SELECT * FROM accounts WHERE id = ${accountId}") // 危险!

对于"IN"子句这种常见难题,Knex提供了

knex.raw('??', ['columnName'])

来动态插入标识符,但处理值列表时,需要手动生成多个占位符(如"?"),并将值作为数组传入。

常见陷阱与进阶安全考量

即使使用了参数化查询,一些陷阱仍需警惕。首先是动态表名或列名问题:参数占位符只能用于值,不能用于SQL标识符(表名、列名)。处理动态标识符时,必须使用白名单验证或ORM提供的特定转义方法(如Sequelize的"sequelize.escape()"或Knex的"??")。其次,警惕二次注入:即使从数据库取出的“安全”数据,如果再次被拼接到新查询中而未经验证,也可能引发注入。第三,注意ORM配置和底层驱动。确保你使用的数据库驱动(如"pg"、"mysql2")支持真正的参数化协议,而不是在客户端模拟。最后,将参数化查询与深度防御策略结合:实施最小权限的数据库账户、对输入进行严格的业务逻辑验证、使用Web应用防火墙(WAF)以及定期进行安全审计和渗透测试。

总结:将安全作为默认习惯

在Node.js ORM中使用参数化原始查询,不是一个可选的优化项,而是一条必须遵守的安全底线。无论项目使用Sequelize、TypeORM、Prisma还是Knex,其安全模式是相通的:分离代码与数据。开发者需要深入理解所选工具的参数化语法,并在代码审查中将其作为重点检查项。通过将安全的查询模式固化为团队习惯,并配合全面的防御措施,才能从根本上构建抵御SQL注入的坚固防线,保护应用数据的核心安全。记住,没有任何功能需求可以成为牺牲安全性的理由。