Node.js必知必会: 如何防止SQL注入

2,472 阅读2分钟

圣诞节临近下班时收到一封安全审核邮件, 大概内容是说存在SQL注入漏洞需要紧急修复(WTF?), 好吧, 圣诞计划泡汤了, 开始修复漏洞吧。

引发SQL注入漏洞的原因

当谈起Web安全时我们最常听到的关键词就是: SQL注入(SQL Injection), 那什么是SQL注入?

SQL注入的漏洞是指: 未经检查或者未经充分检查的用户输入数据,意外变成了代码被执行

再来看一下代码中哪里有该漏洞: 因为历史原因, 执行的SQL是存在数据库中的, 每次查询时会将存在数据库里的SQL语句与用户传递的参数进行拼接, 然后发送给数据库引擎执行, 举个例子:

// 用户查询参数
const query = {
  pageSize: 10
}

let sqlStatement = `select * from users limit {{pageSize}}`

sqlStatement = sqlStatement.replace(/{{(\w+)}}/, value=>{
      return query[value.slice(2, -2)] // 将上述sql中limit替换成10
})

await client.query(sqlStatement)

从上面的代码中我们可以发现: 并没有对用户的数据进行充分的检查, 因此上述代码是存在SQL注入风险的!

攻击者可以修改pageSize为很大, 如果数据库中的数据很多的话, 连续发起这样的请求会导致数据库查询阻塞, 其他用户得不到响应。

再来看另一处代码


const name = "' or 1 =1--max '"

let sqlStatement = `select * from users where name= '${name}'`
await client.query(sqlStatement)

程序中依然没有对用户的数据进行校验, 直接将用户传入的name拼接到SQL语句中并发起查询。

造成的结果是: 本来期望的是根据用户名查询某个用户的信息, 被攻击者通过传入单引号将原SQL语句where条件中的name闭合如name=''成为一个假条件, 然后通过or 1 = 1创造一个永远执行的真条件, 最后通过注释--将后面的语句注释掉以防报错。

原来期望的结果是查询指定用户信息:

[{ name: 'max', age: 28 }]

被SQL注入之后, 攻击者却可以得到全站用户信息, 造成全站用户资料泄漏

[
  { name: 'max', age: 28 },
  { name: 'evle', age: 29 },
  { name: 'sangwoo', age: 30 }
]

修复漏洞

既然知道了产生漏洞的原因, 那让我们下手来修复漏洞吧

修复SQL注入的方法:

不相信用户的输入
不相信用户的输入
不相信用户的输入

校验参数类型

我们从网络收到的数据都是字符串格式的, 比如我们发起一个GET请求并携带pageSize参数

curl 'http://localhost:3000?pageSize=10'

然后我们就可以在程序中得到pageSize参数

{ pageSize: '10' }

从上面的console.log中我们很明显看得出是一个字符串类型, 如果直接将它拼接在SQL语句中就会产生SQL注入的漏洞, 因此我们不能假设用户只会输入字符串类型的整数, 而是限定pageSize是整数类型。

我们需要将pageSize转换成为整型, 并且我们还应该限制pageSize的最大值

const val = parseInt(query.pageSize, 10);
if (isNaN(val) || val > 50) {
  throw new BadRequestException('Validation failed');
}

如果是使用Nest.js作为Node.js的框架则可以使用Pipe来很便捷的完成参数的转换和验证。

在来看一个例子, 有时候我们需要将boolean类型的参数拼接到SQL语句, 拼接的参数只要是字符串, 就可能存在SQL注入的风险

const param = 'true 恶意语句'
'select * from user where is_vip = ' + param

因此我们需要把用户的输入限制的死死的, 如果是这种只能填true或者false,

const isVip = 'true 恶意语句'
'select * from user where is_vip = ' + isVip

const isBoolean = isVip === 'true' || isVip === 'false'
if (!isBoolean) {
  throw new BadRequestException('Validation failed');
}

escape

那如果对于字符串类型的参数该怎么防止SQL注入的漏洞呢? 比如之前的SQL注入漏洞语句

const name = "' or 1 =1--max '"

let sqlStatement = `select * from users where name= '${name}'`
await client.query(sqlStatement)

我们可以借助sqlstring这个库将name转译, 正如该库的README中所说:

In order to avoid SQL Injection attacks, you should always escape any user provided data before using it inside a SQL query

下面改造下我们的代码

const name = "' or 1 =1--max '"

let sqlStatement = `select * from users where name= '${sqlstring.escape(name)}'`
await client.query(sqlStatement)

转译过后的name会变为

'\' or 1 =1--max \''

该语句在执行查询时候会报语法错误, 无法让攻击者得到他们想得到的数据。

一个参数, 一个参数的这样escape有点麻烦, sqlstring还提供了类似mysql的预编译(prepared statements)的使用方法。

var sql    = SqlString.format('UPDATE users SET foo = ?, bar = ?, baz = ? WHERE id = ?',
  ['a', 'b', 'c', userId]);

预编译同样也是防止SQL注入的有效手段, 看上面的写法可以看出点原理是通过占位符预编译了SQL语句, 用户传入的值只是被当作单纯的值, 不会引起已编译SQL语句产生其他逻辑。

有的数据库不支持预编译, 我使用的数据库是clickhouse, 我也不知道支不支持预编译。。。

escape的转译规则和mysql的escape函数一样:

  • Numbers are left untouched
  • Booleans are converted to true / false
  • Date objects are converted to 'YYYY-mm-dd HH:ii:ss' strings
  • Buffers are converted to hex strings, e.g. X'0fa5'
  • Strings are safely escaped
  • Arrays are turned into list, e.g. ['a', 'b'] turns into 'a', 'b'
  • Nested arrays are turned into grouped lists (for bulk inserts), e.g. [['a', 'b'], ['c', 'd']] turns into ('a', 'b'), ('c', 'd')
  • Objects that have a toSqlString method will have .toSqlString() called and the returned value is used as the raw SQL.
  • Objects are turned into key = 'val' pairs for each enumerable property on the object. If the property's value is a function, it is skipped; if the property's value is an object, toString() is called on it and the returned value is used.
  • undefined / null are converted to NULL NaN / Infinity are left as-is. MySQL does not support these, and trying to insert them as values will trigger MySQL errors until they implement support.

其中第五点很关键: 会将string类型的参数安全转译, 查看源码第202行我们会看到一些可能会被恶意注入执行的字符将会被转译为无法执行的字符串

var CHARS_ESCAPE_MAP    = {
  '\0'   : '\\0',
  '\b'   : '\\b',
  '\t'   : '\\t',
  '\n'   : '\\n',
  '\r'   : '\\r',
  '\x1a' : '\\Z',
  '"'    : '\\"',
  '\''   : '\\\'',
  '\\'   : '\\\\'
};

while ((match = CHARS_GLOBAL_REGEXP.exec(val))) {
  escapedVal += val.slice(chunkIndex, match.index) + CHARS_ESCAPE_MAP[match[0]];
  chunkIndex = CHARS_GLOBAL_REGEXP.lastIndex;
}

写在最后的

Node.js版本的postgres驱动pg, 在查询时候默认提供了类预编译的查询方式来预防SQL注入。

const text = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *'
const values = ['brianc', 'brian.m.carlson@gmail.com']
// callback
client.query(text, values, (err, res) => {
  if (err) {
    console.log(err.stack)
  } else {
    console.log(res.rows[0])
    // { name: 'brianc', email: 'brian.m.carlson@gmail.com' }
  }
})

现在大部分数据库的Driver都提供了类似的查询方式供我们使用, 即使没有做防SQL的注入的driver(比如我现在使用的clickhouse driver), 我们也可以通过sqlstring封装一个安全的query方法来预防SQL注入攻击。

query(query, values) {
    const formattedQuery = sqlstring.format(query, values);
    ...
}

好了, 经过2个半小时的修改, 终于检查完历史接口所有用户参数并做了安全处理,也通过安审人员的检查。

如果本文对你有帮助, 点个赞吧~