前后端分离模式下的安全解决方案
前言
在前后端分离的开发模式中,从开发的角色和职能上来讲,一个最明显的变化就是:以往传统中,只负责浏览器环境中开发的前端同学,需要涉猎到服务端层面,编写服务端代码。而摆在面前的一个基础性问题就是如何保障Web安全?
本文就在前后端分离模式的架构下,针对前端在Web开发中,所遇到的安全问题以及应对措施和注意事项,并提出解决方案。
跨站脚本攻击(XSS)的防御
问题及解决思路
跨站脚本攻击(XSS,Cross-site scripting)是最常见和基本的攻击Web网站的方法。攻击者可以在网页上发布包含攻击性代码的数据,当浏览者看到此网页时,特定的脚本就会以浏览者用户的身份和权限来执行。通过XSS可以比较容易地修改用户数据、窃取用户信息以及造成其它类型的攻击,例如:CSRF攻击。
预防XSS攻击的基本方法是:确保任何被输出到HTML页面中的数据以HTML的方式进行转义(HTML escape)。例如下面的模板代码:
<textarea name="description">$description</textarea>这段代码中的
$description
为模板的变量(不同模板中定义的变量语法不同,这里只是示意一下),由用户提交的数据,那么攻击者可以输入一段包含”JavaScript”的代码,使得上述模板语句的结果变成如下的结果:
<textarea name="description"> </textarea><script>alert('hello')'</script> </textarea>上述代码,在浏览器中渲染,将会执行JavaScript代码并在屏幕上alert hello。当然这个代码是无害的,但攻击者完全可以创建一个JavaScript来修改用户资料或者窃取cookie数据。
解决方法很简单,就是将$description
的值进行html escape,转义后的输出代码如下:
<textarea name="description">
</textarea><script>alert("hello!")</script>
</textarea>
以上经过转义后的HTML代码是没有任何危害的。
Midway的解决方案
转义页面中所有用户输出的数据
对数据进行转义有以下几种情况和方法:
1. 使用模板内部提供的机制进行转义
中途岛内部使用KISSY xtemplate作为模板语言。
在xtemplate实现中,语法上使用两个中括号( {{val}}
)解析模板数据, ,默认既是对数据进行HTML转义的,所以开发者可以这样写模板:
<textarea name="description">{{description}}</textarea>
在xtemplate中,如果不希望输出的数据被转义,需要使用三个中括号({{{val}}}
)。
2. 在Midway中明确的调用转义函数
开发者可以在Node.js程序或者模板中,直接调用Midway提供的HTML转义方法,显示的对数据进行转义,如下:
方法1:在Node.js程序中对数据进行HTML转义
var Security= require('midway-security'); //data from server,eg {html:'</textarea>',other:""} data.html =Security.escapeHtml(data.html); xtpl = xtpl.render(data);方法2:在模板中对HTML数据进行HTML转义
<textarea name="description">Security.escapeHtml({{{description}}})</textarea>
注意:只有当模板内部没有对数据进行转义的时候才使用Security.escapeHtml进行转义。 否则,模板内部和程序会两次转义叠加,导致不符合预期的输出。
推荐:如果使用xtemplate,建议直接使用模板内置的{{}}
进行转义; 如果使用其他模板,建议使用Security.escapeHtml
进行转义。
过滤页面中用户输出的富文本
你可能会想到:“其实我就是想输出富文本,比如一些留言板、论坛给用户提供一些简单的字体大小、颜色、背景等功能,那么我该如何处理这样的富文本来防止XSS呢?”
1. 使用Midway中Security提供的richText函数
Midway中提供了richText方法,专门用来过滤富文本,防止XSS、钓鱼、cookie窃取等漏洞。
有一个留言板,模板代码可能如下:
<div class="message-board"> {{{message}}} </div>因为message是用户的输入数据,其留言板的内容,包含了富文本信息,所以这里在xtemplate中,使用了三个大括号,默认不进行HTML转义;那么用户输入的数据假如如下:
<script src="http://eval.com/eval.js"></script><span style="color:red;font-size:20px;position:fixed;">我在留言中</span>
上述的富文本数据如果直接输出到页面中,必然会导致eval.com站点的js注入到当前页面中,造成了XSS攻击。为了防止这个漏洞,我们只要在模板或者程序中,调用Security.richText方法,处理用户输入的富文本。
调用方法与escapeHtml类似,有如下两种方式。
方法1: 直接在Node.js程序中调用
message =Security.richText(message); var html = xtpl.render(message)方法2: 在模板中调用
<div class="message-board"> Security.richText({{{message}}}) </div>通过调用Security的richText方法后,最终的输出如下:
<div class="message-board"> <span style="color:red;font-size:20px;">我在留言中</span> </div>
可以看出,首先:会造成XSS攻击的script
标签被直接过滤掉;同时style标签中CSS属性position:fixed;
样式也被过滤了。最终输出了无害的HTML富文本
了解其他可能导致XSS攻击的途径
除了在页面的模板中可能存在XSS攻击之外,在Web应用中还有其他几个途径也可能会有风险。
1. 出错页面的漏洞
一个页面如果找不到,系统可能会报一个404 Not Found的错误,例如:http://localhost/page/not/found
404 NotFound Page /page/not/found does not exsit
很显然:攻击者可以利用这个页面,构造一个类似这样的连接,http://localhost/%3Cscript%3Ealert%28%27hello%27%29%3C%2Fscript%3E
,并引诱受害者点击 ;假如出错页面未对输出变量进行转义的话,那么连接中隐藏的<script>alert('hello')</script>
将会被执行。
在express中,发送一个404页面的方法如下:
res.send(404,'Sorry,we don\'t find that!')
这里就需要开发者注意错误页面(404或者其他错误状态)的处理方式。如果错误信息的返回内容带有路径信息(其实更准确的讲,是用户输入信息),就一定要进行escapeHtml了。
后续,错误处理的安全机制,会在Midway框架层面中完成。
Midway解决方案的补充说明
其他模板引擎
Midway默认支持xtemplate模板,但将来也有可能支持其他模板:如jade、mustache、ejs等。目前在主流模板中,都提供了默认转义和不转义的输出变量写法,需要开发者特别留意其安全性。
关于escape的其他支持
除了对页面中输出的普通数据和富文本数据,一些场景中也还包含其他可能需要转义的情况,Midway提供了如下几个常用的转义方法,供开发者使用:
- escapeHtml 过滤指定的HTML中的字符,防XSS漏洞
- jsEncode 对输入的String进行JavaScript 转义,对中文进行unicode转义,单引号,双引号转义
- escapeJson 不破坏JSON结构的escape函数,只对json结构中name和vaule做escapeHtml处理
- escapeJsonForJsVar 可以理解就是jsEncode+escapeJson
例子如下:
var jsonText ="{\"<script>\":\"<script>\"}"; console.log(SecurityUtil.escapeJson(jsonText));// {"<script>":"<script>"} var jsonText ="{\"你好\":\"<script>\"}"; console.log(SecurityUtil.escapeJsonForJsVar(jsonText));//{\"\u4f60\u597d\":\"<script>\"} var str ="alert(\"你好\")"; console.log(SecurityUtil.jsEncode(str));// alert(\"\u4f60\u597d\")
跨站请求伪造攻击(CSRF)的预防
问题及解决思路
名词解释: 表单:泛指浏览器端用于客户端提交数据的形式;包括a标签、ajax提交数据、form表单提交数据等,而非对等于HTML中的form标签。
跨站请求伪造(CSRF,Cross-site request forgery)是另一种常见的攻击。攻击者通过各种方法伪造一个请求,模仿用户提交表单的行为,从而达到修改用户的数据或执行特定任务的目的。
为了假冒用户的身份,CSRF攻击常常和XSS攻击配合起来做,但也可以通过其它手段:例如诱使用户点击一个包含攻击的链接。
解决CSRF攻击的思路分如下两个步骤
- 增加攻击的难度。GET请求是很容易创建的,用户点击一个链接就可以发起GET类型的请求,而POST请求相对比较难,攻击者往往需要借助JavaScript才能实现;因此,确保form表单或者服务端接口只接受POST类型的提交请求,可以增加系统的安全性。
- 对请求进行认证,确保该请求确实是用户本人填写表单或者发起请求并提交的,而不是第三者伪造的。
一个正常用户修改网站信息的过程如下:
- 用户请求修改信息(1) -> 网站显示用户修改信息的表单(2) -> 用户修改信息并提交(3) -> 网站接受用户修改的数据并保存(4)
而一个CSRF攻击则不会走这条路线,而是直接伪造第2步用户提交信息:
- 直接跳到第2步(1) -> 伪造要修改的信息并提交(2) -> 网站接受攻击者修改参数数据并保存(3)
只要能够区分这两种情况,就能够预防CSRF攻击。那么如何区分呢? 就是对第2步所提交的信息进行验证,确保数据源自第一步的表单。具体的验证过程如下:
- 用户请求修改信息(1) -> 网站显示用于修改信息的空白表单,表单中包含特殊的token同时把token保存在session中(2) -> 用户修改信息并提交,同时发回token信息到服务端(3) -> 网站比对用户发回的token和session中的token,应该一致,则接受用户修改的数据,并保存
这样,如果攻击者伪造要修改的信息并提交,是没办法直接访问到session的,所以也没办法拿到实际的token值;请求发送到服务端,服务端进行token校验的时候,发现不一致,则直接拒绝此次请求。
Midway解决方案
禁用GET提交表单
如果服务端不接受GET方式提交的表单数据,那么将会给攻击者带来非常大的难度;因为在页面上构造一个a标签href属性或者img标签src属性来构造一个请求是非常容易的,但是如果要POST提交,就必须要通过脚本才可以实现。
用CSRF token验证请求
因为Midway不涉及到淘宝分布式session及token校验这一层面逻辑,所以在Midway框架中,只将token在server和客户端之间进行转发,本身不做实际的校验工作。流程如下:
图1
其他安全问题
关于常见的Web安全问题,还有如下几种,这里只做一些简介,后续会持续继承到Midway framework中。
- HTTP Headers安全
- CRLF Injection 攻击者想办法在响应头中注入两个CRLF特殊字符,导致响应数据格式异常,从而注入script等
- 拒绝访问攻击 每个请求因为都会默认带上cookie,而服务器一般都会限制cookie的大小,这就导致了,如果用户客户端cookie被设置成了超过某个阀值,那么用户就再也无法访问网站了
- cookie防窃取 一般cookie窃取都是通过JavaScript(XSS漏洞)获取到的,所以尽量将cookie设置成http only,并且加上cookie过期时间
关于cookie的安全问题,之前WebX已经有较好的解决方案;此次Midway不负责cookie的设置和校验等工作,只负责转发到WebX层面进行check
关于Node.js
XSS等注入性漏洞是所有漏洞中最容易被忽略,占互联网总攻击的70%以上;开发者编写Node.js代码时,要时刻提醒自己,永远不要相信用户的输入。
比如下面几个例子。
-
var mod = fs.readFileSync('path');
如果path来源于用户输入,那么假设用户输入/etc/password
,则会读取到不应该读取的内容,造成密码泄漏风险 -
var result = eval(jsonVal);
一定要确保jsonVal是json,而不是用户的输入 - …… 其他可能包含用户输入的地方,一定要确认用户的输入是我们期望的值
总结
前后端分离模式下,可以让传统的前端开发人员开始编写后端代码,虽然从架构上讲,只负责模板这一层,但也会接触大量的后端代码;所以安全对于前端来说,这是一个不小的挑战。
基于前后端分离的多终端适配
前言
近年来各站点基于 Web 的多终端适配进行得如火如荼,行业间也发展出依赖各种技术的解决方案。有如基于浏览器原生 CSS3 Media Query 的响应式设计、基于云端智能重排的「云适配」方案等。本文则主要探讨在前后端分离基础下的多终端适配方案。
关于前后端分离
关于前后端分离的方案,在《前后端分离的思考与实践(一)》中有非常清晰的解释。我们在服务端接口和浏览器之间引入 NodeJS 作为渲染层,因为 NodeJS 层彻底与数据抽离,同时无需关心大量的业务逻辑,所以十分适合在这一层进行多终端的适配工作。
UA 探测
进行多终端适配首先要解决的是 UA 探测问题,对于一个过来的请求,我们需要知道这个设备的类型才能针对对它输出对应的内容。现在市面上已经有非常成熟的兼容大量设备的 User Agent 特征库和探测工具,这里有 Mozilla 整理的一个列表。其中,既有运行在浏览器端的,也有运行在服务端代码层的,甚至有些工具提供了 Nginx/Apache 的模块,负责解析每个请求的 UA 信息。
实际上我们推荐最后一种方式。基于前后端分离的方案决定了 UA 探测只能运行在服务器端,但如果把探测的代码和特征库耦合在业务代码里并不是一个足够友好的方案。我们把这个行为再往前挪,挂在 Nginx/Apache 上,它们负责解析每个请求的 UA 信息,再通过如 HTTP Header 的方式传递给业务代码。
这样做有几点好处:
- 我们的代码里面无需再去关注 UA 如何解析,直接从上层取出解析后的信息即可。
- 如果在同一台服务器上有多个应用,则能够共同使用同一个 Nginx 解析后的 UA 信息,节省了不同应用间的解析损耗。
图2 来自天猫分享的基于Nginx的UA探测方案
淘宝的 Tengine Web 服务器也提供了类似的模块 ngx_http_user_agent_module。
值得一提的是,选用UA探测工具时必须要考虑特征库的可维护性,因为市面上新增的设备类型越来越多,每个设备都会有独立的 User Agent,所以该特征库必须提供良好的更新和维护策略,以适应不断变化的设备。
建立在 MVC 模式中的几种适配方案
取得 UA 信息后,我们就要考虑如果根据指定的 UA 进行终端适配了。即使在 NodeJS 层,虽然没有了大部分的业务逻辑,但我们依然把内部区分为 Model / Controller / View 三个模型。
图3
我们先利用上面的图,去解析一些已有的多终端适配方案。
建立在 Controller 上的适配方案
图4
这种方案应该是最简单粗暴的处理方法。通过路由(Router)将相同的 URL 统一传递到同一个控制层(Controller)。控制层再通过 UA 信息将数据和模型(Model)逻辑派发到对应的展现(View)进行渲染,渲染层则按预先的约定提供了适配几个终端的模板。
这种方案的好处是,保持了数据和控制层的统一性,业务逻辑只需处理一次遍可以应用在所有终端上。但这种场景只适合如展示型页面等低交互型的应用,一旦业务比较复杂,各个终端的 Controller 可能有各自的处理逻辑,如果还是共用一个 Controller ,会导致 Controller 非常的臃肿而且难以维护,这无疑是一个错误的选择。
建立在 Router 上的适配方案
为了解决上面遇到的问题,我们可以在 Router 上就将设备区分,针对不同的终端分发到不同的 Controller 上:
图5
这也是最常见的方案之一,大多表现在针对不同终端使用各自独立的一套应用。如 PC 淘宝首页和 WAP 版的淘宝首页,不同设备访问 www.taobao.com ,服务器会通过 Router 的控制,重定向到 WAP 版的淘宝首页或者 PC 版的淘宝首页,它们各自是完全独立的两套应用。
但这种方案无疑带来了数据和部分逻辑无法共用的问题,各种终端之间无法分享同一份数据和业务逻辑,产生大量重复性工作,效率低下。
为了缓解这个问题,有人提出了优化后的方案:依然是在同一套应用里面,各个数据来源抽象成各个 Model,提供给不同终端的 Controller 组合使用:
图6
这个方案解决了前面数据无法共用的问题。在 Controller 上各个终端还是相互独立,但能共同使用同一批数据源,至少在数据上无需再针对终端类型开发独立的接口了。
以上两种基于 Router 的方案,由于 Controller 的独立,各个终端可以为自己的页面实现不同的交互逻辑,保证了各终端自身足够的灵活度,这也是为什么大部分应用采用这种方案的主要原因。
建立在 View 层的适配方案
这是淘宝下单页面使用的方案,不过区别是下单页将整体的渲染层放在了浏览器端,而不是 NodeJS 层。不过无论是浏览器还是 NodeJS,整体设计思路还是一致的:
图7
在这个方案里面,Router、Controller 和 Model 都无需关注设备信息,终端类型的判断完全交给展现层来处理。图中主要的模块是「View Factory」,Model 和 Controller 将数据和渲染逻辑传递过来之后,通过 View Factory 根据设备信息和其它状态(不仅仅是 UA 信息、也可以是网络环境、用户地区等等)从一堆预设好的组件(View Component)中抓取特定的组件,再组合成最终的页面。
这种方案有几个优势:
- 上层无需关注设备信息(UA),多终端的视频还是交由和最终展现最大关系的 View 层来处理;
- 不仅仅是多终端适配,除了 UA 信息,各个 View Component 还可以根据用户状态决定自身输出何种模版,如低网速下默认隐藏图片、指定地区输出活动 Banner。
- 每个 View Component 的不同模版间可以自行决定是否使用同一份数据、业务逻辑,提供十分灵活的实现方式。
但明显的是,这个方案也是最复杂的,尤其是要考虑一些富交互的应用场景时,Router 和 Controller 也许无法保持这么纯粹。特别对于一些整体性比较强的业务,本身无法被拆分成组件,这种方案也许并不适用;而且对于一些简单的业务,使用这种架构可能不是最佳的选择。
总结
以上几种方案,都各自体现在 MVC 模型中的一个或多个部分,在业务上如果一个方案不满足需求,更可以采取多个方案同时采用的方式。或是可以理解为,业务上的复杂度和交互属性决定了该产品更适合采用哪种多终端适配方案。
对比基于浏览器的响应式设计方案,因为绝大部分终端探测和渲染逻辑迁移到了服务端,所以在 NodeJS 层进行适配无疑带来了更好的性能和用户体验;另外,相对于一些所谓的「云适配」方案带来的转换质量问题,在基于前后端分离的「定制式」方案中也不会存在。前后端分离的适配方案在这些方面有着天然优势。
最后,为了适应更灵活的强大的适配需求,基于前后端分离的适配方案将会面临更多挑战!
Nginx + Node.js + Java 的软件栈部署实践
起
关于前后端分享的思考,我们已经有五篇文章阐述思路与设计。本文介绍淘宝网收藏夹将 Node.js 引入传统技术栈的具体实践。
淘宝网线上应用的传统软件栈结构为 Nginx + Velocity + Java,即:图8
在这个体系中,Nginx 将请求转发给 Java 应用,后者处理完事务,再将数据用 Velocity 模板渲染成最终的页面。
引入 Node.js 之后,我们势必要面临以下几个问题:
- 技术栈的拓扑结构该如何设计,部署方式该如何选择,才算是科学合理?
- 项目完成后,该如何切分流量,对运维来说才算是方便快捷?
- 遇到线上的问题,如何最快地解除险情,避免更大的损失?
- 如何确保应用的健康情况,在负载均衡调度的层面加以管理?
承
系统拓扑
按照我们在前后端分离的思考与实践(二)- 基于前后端分离的模版探索一文中的思路,Velocity 需要被 Node.js 取代,从而让这个结构变成:
图9
图10
部署方案
上面的结构看起来没什么问题了,但其实新问题还等在前面。在传统结构中,Nginx 与 Java 是部署在同一台服务器上的,Nginx 监听 80 端口,与监听高位 7001 端口的 Java 通信。现在引入了 Node.js ,需要新跑一个监听端口的进程,到底是将 Node.js 与 Nginx + Java 部署在同一台机器,还是将 Node.js 部署在单独的集群呢?
我们来比较一下两种方式各自特点:
淘宝网收藏夹是一个拥有千万级日均 PV 的应用,对稳定性的要求性极高(事实上任何产品的线上不稳定都是不能接受的)。如果采用同集群部署方案,只需要一次文件分发,两次应用重启即可完成发布,万一需要回滚,也只需要操作一次基线包。性能上来说,同集群部署也有一些理论优势(虽然内网的交换机带宽与延时都是非常乐观的)。至于一对多或者多对一的关系,理论上可能做到服务器更加充分的利用,但相比稳定性上的要求,这一点并不那么急迫需要去解决。所以在收藏夹的改造中,我们选择了同集群部署方案。
灰度方式
为了保证最大程度的稳定,这次改造并没有直接将 Velocity 代码完全去掉。应用集群中有将近 100 台服务器,我们以服务器为粒度,逐渐引入流量。也就是说,虽然所有的服务器上都跑着 Java + Node.js 的进程,但 Nginx 上有没有相应的转发规则,决定了获取这台服务器上请求宝贝收藏的请求是否会经过 Node.js 来处理。其中 Nginx 的配置为:
location = "/item_collect.htm" { proxy_pass http://127.0.0.1:6001; # Node.js 进程监听的端口 }
只有添加了这条 Nginx 规则的服务器,才会让 Node.js 来处理相应请求。通过 Nginx 配置,可以非常方便快捷地进行灰度流量的增加与减少,成本很低。如果遇到问题,可以直接将 Nginx 配置进行回滚,瞬间回到传统技术栈结构,解除险情。
第一次发布时,我们只有两台服务器上启用了这条规则,也就是说大致有不到 2% 的线上流量是走 Node.js 处理的,其余的流量的请求仍然由 Velocity 渲染。以后视情况逐步增加流量,最后在第三周,全部服务器都启用了。至此,生产环境 100% 流量的商品收藏页面都是经 Node.js 渲染出来的(可以查看源代码搜索 Node.js 关键字)。
转
灰度过程并不是一帆风顺的。在全量切流量之前,遇到了一些或大或小的问题。大部分与具体业务有关,值得借鉴的是一个技术细节相关的陷阱。
健康检查
在传统的架构中,负载均衡调度系统每隔一秒钟会对每台服务器 80 端口的特定 URL 发起一次 get
请求,根据返回的 HTTP Status Code 是否为200
来判断该服务器是否正常工作。如果请求 1s 后超时或者 HTTP Status Code 不为 200
,则不将任何流量引入该服务器,避免线上问题。
这个请求的路径是 Nginx -> Java -> Nginx,这意味着,只要返回了 200
,那这台服务器的 Nginx 与 Java 都处于健康状态。引入 Node.js 后,这个路径变成了 Nginx -> Node.js -> Java -> Node.js -> Nginx。相应的代码为:
var http = require('http'); app.get('/status.taobao', function(req, res) { http.get({ host: '127.1', port: 7001, path: '/status.taobao' }, function(res) { res.send(res.statusCode); }).on('error', function(err) { logger.error(err); res.send(404); }); });
但是在测试过程中,发现 Node.js 在转发这类请求的时候,每六七次就有一次会耗时几秒甚至十几秒才能得到 Java 端的返回。这样会导致负载均衡调度系统认为该服务器发生异常,随即切断流量,但实际上这台服务器是能够正常工作的。这显然是一个不小的问题。
排查一番发现,默认情况下, Node.js 会使用 HTTP Agent
这个类来创建 HTTP 连接,这个类实现了 socket 连接池,每个主机+端口对的连接数默认上限是 5。同时HTTP Agent
类发起的请求中默认带上了 Connection: Keep-Alive
,导致已返回的连接没有及时释放,后面发起的请求只能排队。
最后的解决办法有三种:
- 禁用
HTTP Agent
,即在在调用get
方法时额外添加参数agent: false
,最后的代码为:
var http = require('http'); app.get('/status.taobao', function(req, res) { http.get({ host: '127.1', port: 7001, agent: false, path: '/status.taobao' }, function(res) { res.send(res.statusCode); }).on('error', function(err) { logger.error(err); res.send(404); }); });
- 设置
http
对象的全局 socket 数量上限:
http.globalAgent.maxSockets = <span class="hljs-number">1000</span>;
- 在请求返回的时候及时主动断开连接:
http.get(options, <span class="hljs-function"><span class="hljs-keyword">function</span><span class="hljs-params">(res)</span> {</span> }).on(<span class="hljs-string">"socket"</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(socket)</span> {</span> socket.emit(<span class="hljs-string">"agentRemove"</span>); <span class="hljs-comment">// 监听 socket 事件,在回调中派发 agentRemove 事件</span> });
实践上我们选择第一种方法。这么调整之后,健康检查就没有再发现其它问题了。
合
Node.js 与传统业务场景结合的实践才刚刚起步,仍然有大量值得深入挖掘的优化点。比比如,让 Java 应用彻底中心化后,是否可以考分集群部署,以提高服务器利用率。或者,发布与回滚的方式是否能更加灵活可控。等等细节,都值得再进一步研究。
本文转自: http://ued.taobao.org/blog/2014/05/midway-deploy/