事件原因:
之前使用Nodejs开发的一个网站。在网站上有一个页面有个功能,允许用户上传图片或者粘贴一张图片链接。服务端读取用户上传的图片信息或者是请求用户填写的图片链接获取图片信息。
如果是用户使用上传功能,前端可以在input控件上做下限制上传文件的类型,后端再做下校验,以保障获取图片文件的合法(或者是安全)。
如果是用户填写的图片链接,其中隐藏的安全隐患就比较大了。如果用户填写不是正常的http
、https
或者是ftp
类的链接,而是curl
这种可以在服务端执行的命令,服务端拿到用户的链接不作处理直接请求的话,可能会对公司造成不可估量的额损失(比如:内网服务器信息泄露、更严重是内网服务器被攻击)。
事件的处理方法:
在公司是有专门的安全组来做Web安全这块儿工作的。这个事件发生的时候,我首先是短信收到了服务器的报警,线上出现了大量的FATAL日志。这个时候立刻是登上服务器排查,紧接着就被公司的安全组某位同事在内部聊天工具上通知,网站收到了ssrf(服务器端请求伪造)攻击。第一时间对这个接口进行了下线处理,然后评估了安全的解决方案,再次上线该接口。
那么在服务端具体的防范ssrf攻击的方法时什么呢,请接着往下看。
服务端的解决方案
ssrf具体原理就是在服务端伪造请求,请求服务器的资源。上面的案例场景我也提到了。我有个功能是会请求一张外网图片链接获取图片信息的。而用户填写的这个图片链接很可能就是违法的请求。当我服务端拿到这个链接后,并没有按照预期去请求外网资源,而是请求了内网服务的资源。这样我的服务器就被攻击。
那么什么原因可能造成ssrf漏洞呢:
- web服务存在请求外网资源的功能
- 请求的url参数是外接用户可以控制的
- 服务端在请求外网资源前,没有做域名和IP校验(是否请求的是内网资源)
- 仅仅通过正则匹配来判断IP段(域名指向的也可能是内网IP,如果IP是十进制表示法没有问题,但IP还有十六进制和十进制、二进制表示法)
由上面提到的原因其实已经可以知道一些解决方法:
1.校验用户填写链接的协议头
在拿到用户填写的链接时做下校验。看是否是合法的http
、https
、ftp
协议头。当然这个校验为了减轻服务器压力应该在前端和后端都校验。如不过不是合法的请求协议头直接拒绝和返回请求资源失败的错误消息。(伪代码如下)
const allowReqprotocol = [
'http',
'https',
'ftp',
];
const reqProtocol = request.protocol;
// 强制校验用户输入图片链接的协议头是否为自己网站允许的协议头
if (allowReqprotocol.indexOf(reqProtocol) < 0) {
yog.log.warning(imgUrl);
res.json(errorMsg.imgTypeError());
return;
}
2.检验用户请求的IP指向的是否为内网ip
上面已经介绍过仅仅通过正则没办法完全校验是否是合法的IP段。其实还有一种方法就是把我们的IP段转化为int来进行判断。刚好npm上有个包ip-to-int可以满足我们的需求。我们可以把两者结合起来一起来判断请求链接的IP是否为内网IP。
const dns = require('dns');
const url = require('url');
const ipToInt = require('ip-to-int');
const parseUrl = url.parse(rqUrlString);
const hostname = parseUrl.hostname;
// 使用Nodejs的dns模块根据域名解析出域名对应的ip地址
dns.lookup(hostname, (err, address, family) => {
//加入我的内网IP段为 180.xxx.xxx.xxx 180.255.255.255
const ipRegx = /^180\./;
const ipArr = [ipToInt(180.0.0.0).toInt(), ipToInt(180.255.255.255).toInt()];
if(ipRegx.test(address) || ipRang(address)){
// 拒绝请求
} else {
// 继续请求
}
});
// 判断一个ip是否在一个数组之间
function ipRang(ip, array) {
// ip在给定的ip段之间
return true;
// ip不在给定的ip段之间
return false;
}
3.最后的检验
经过上面的检验已经基本上可以保证如果有请求外网的Url ,请求的在服务端执行以后指向的也是外网资源。那么当资源到达服务端以后,为了保障资源的安全性。我们对请求的内容做最后的校验。比如我仅仅是想请求图片资源,而且是限制了几种格式。比如:png、jpg、jpeg。
const allowImgContentType = [
'image/jpeg',
'image/png',
'image/gif'
];
if( allowImgContentType.indexOf(response.headers['content-type']) < 0){
// 返回的内容为非法资源
}
4.假如允许请求内网资源
上面的方法基本上都是如果请求的是内网资源都是直接拒绝掉了。但是假如有请求内网的需求怎么办呢。如果内网的资源对外部开发,肯定也是特定的机器。这些机器也肯定是经过op做特殊的隔离处理的、没有敏感的公司信息资源。那么当我们的请求到达公司内网后怎么保证该请求始终请求的是特定的IP呢?
其实我们只需要将请求的链接的host和在header投中和请求机器的ip做下绑定就好。
// 通过dns模块解析出host对应的ip 这里是address
// 并且通过url模块解析出port path 和querystring 重新拼接请求的url
const bindURLString = `${parseUrl.protocol}//${address}:${reqUrlPort}${pathName}?${queryString}`;
// 然后在请求发出去的时候设置下header头,下面是我使用request模块发出请求时设置参数
const options = {
'url': bindURLString,
'encoding': 'binary',
'rejectUnauthorized': false,
'headers': {
'Host': bindDnsObj.hostname //这里绑定请求的host
}
};
这样做就是强制请求去请求特定的机器。而不会请求其它机器。