简版速记

面试速记用,覆盖本篇核心结论。

  • XSS(跨站脚本):把恶意脚本注入到页面里执行。分三种——反射型(参数回显到页面)、存储型(恶意脚本存进数据库再吐给所有用户,危害最大)、DOM-based(纯前端 JS 操作 DOM 触发,不经过后端)。
  • XSS 防御:① 对输入/输出做转义(<>"'`/);② 富文本用白名单过滤(如 js-xss),不要用黑名单;③ Cookie 设 HttpOnly(禁止 JS 读 cookie)+ Secure(仅 HTTPS 发送);④ 现代做法再加 CSP(Content-Security-Policy) 兜底。
  • CSRF(跨站请求伪造):利用浏览器自动携带目标域 cookie 的特性,诱导已登录用户向目标站发起恶意请求。攻击者拿不到 cookie,只是「借用」登录态——这是和 XSS 的本质区别。
  • CSRF 防御:① GET 不做数据变更(遵循语义安全);② CSRF Token(最通用,前端拿不到第三方页面里的 token);③ SameSite CookieStrict/Lax,现代浏览器默认 Lax);④ 校验 Origin/Referer;⑤ 验证码。
  • 同源策略 SOP:协议 + 域名 + 端口三者全相同才同源。SOP 不是「禁止跨域请求」,而是「拦截跨域请求的响应结果」——请求其实已经发出去了,所以 SOP 不能防 CSRF
  • CORS:服务器通过 Access-Control-Allow-Origin 等响应头主动「解锁」跨域。简单请求(GET/POST/HEAD + 受限 Content-Type)直接发出 → 会造成 CSRF;非简单请求先发 OPTIONS 预检,预检拦得住的请求才是「能防 CSRF 的例外」。
  • CORS 携带 cookie:跨域请求默认不带 cookie,需前端 withCredentials: true + 后端 Access-Control-Allow-Credentials: true,且此时 Allow-Origin 不能为 *
  • 密码安全:数据库绝不明文存密码;加盐 + 慢哈希(现代用 bcrypt / scrypt / Argon2,不要再用裸 md5/sha1)防彩虹表;登录限速/验证码防暴力破解;错误提示统一为「账号或密码错误」。

1 代码注入XSS

跨网站指令码(英语:Cross-site scripting,通常简称为:XSS)是一种网站应用程式的安全漏洞攻击,是代码注入的一种。它允许恶意使用者将程式码注入到网页上,其他使用者在观看网页时就会受到影响。这类攻击通常包含了 HTML 以及使用者端脚本语言

XSS 分为三种:反射型,存储型和 DOM-based

补充(现代做法):三种 XSS 的区别要点——反射型:恶意脚本来自当前 HTTP 请求(如 URL 参数),服务器原样回显到页面,需诱导受害者点击特制链接,不持久。存储型:恶意脚本被持久化保存在服务器(评论、昵称、留言等),任何浏览到该内容的用户都会中招,危害最大。DOM-based:漏洞在前端 JS 中,恶意数据从 locationdocument.referrer 等 source 流入 innerHTMLdocument.write 等 sink,整个过程不经过服务器,后端转义无法防御,需在前端避免危险 sink。

1.1 如何攻击

  • XSS 通过修改 HTML节点或者执行 JS代码来攻击网站。
  • 例如通过 URL 获取某些参数
    <!-- http://www.domain.com?name=<script>alert(1)</script> -->
    <div>{{name}}</div>    

上述 URL 输入可能会将 HTML 改为 <div><script>alert(1)</script></div> ,这样页面中就凭空多了一段可执行脚本。这种攻击类型是反射型攻击,也可以说是 DOM-based 攻击

1.2 如何防御

最普遍的做法是转义输入输出的内容,对于引号,尖括号,斜杠进行转义

    function escape(str) {
    	str = str.replace(/&/g, "&amp;");
    	str = str.replace(/</g, "&lt;");
    	str = str.replace(/>/g, "&gt;");
    	str = str.replace(/"/g, "&quot;");
    	str = str.replace(/'/g, "&#39;");
    	str = str.replace(/`/g, "&#96;");
        str = str.replace(/\//g, "&#x2F;");
        return str
    }

通过转义可以将攻击代码 <script>alert(1)</script> 变成

    // -> &lt;script&gt;alert(1)&lt;&#x2F;script&gt;
    escape('<script>alert(1)</script>')

对于显示富文本来说,不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。这种情况通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式

    var xss = require("xss");
    var html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>');
    // -> <h1>XSS Demo</h1>&lt;script&gt;alert("xss");&lt;/script&gt;
    console.log(html);

以上示例使用了 js-xss来实现。可以看到在输出中保留了 h1 标签且过滤了 script 标签

补充(现代做法):手写转义/正则容易漏,富文本清洗优先用经过审计的成熟库——浏览器端常用 DOMPurifyDOMPurify.sanitize(dirtyHtml)),Node 端可用 js-xsssanitize-html。框架层面,React/Vue 默认对插值做转义,但 dangerouslySetInnerHTMLv-html 会绕过保护,使用前必须先 sanitize。

补充(现代做法):最有效的纵深防御是 CSP(Content-Security-Policy) 响应头。例如 Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<随机值>',禁用内联脚本与 eval,即使脚本被注入也无法执行。现代实践推荐 nonce/hash 方式而非 unsafe-inline

XSS(跨站脚本攻击)是指攻击者在返回的 HTML 中嵌入 javascript 脚本,为了减轻这些 攻击,需要在 HTTP 头部配上,set- cookie

  • httpOnly 这个属性可以防止 XSS,它会禁止 javascript 脚本来访问 cookie
  • secure- 这个属性告诉浏览器仅在请求为 https 的时候发送 cookie

补充(现代做法):HttpOnly 只是让 document.cookie 读不到 cookie,并不能阻止 XSS 本身,攻击者仍可借受害者身份发请求。把敏感令牌放进 HttpOnly cookie 只是缩小被盗范围,真正的根治还是要做输入输出转义 + CSP。

2 跨站请求伪造CSRF

CSRF 攻击时序:用户登录 A 站获得 cookie 后访问 B 站,B 站诱导浏览器向 A 站发请求并自动带上 A 站 cookie,A 站误以为是用户本人操作

  • CSRF 就是利用用户的登录态发起恶意请求
  • CSRF(Cross-site request forgery) 跨站请求伪造,是一种常见的攻击方式。是指 A 网站正常登陆后,cookie 正常保存登录信息,其他网站 B 通过某种方式调用 A 网站接口进行操作,A 的接口会在请求时会自动带上 cookie
  • 同源策略可以通过 html 标签加载资源,而且同源策略不阻止接口请求而是拦截请求结果,CSRF 恰恰占了这两个便宜。
  • 对于 GET 请求,直接放到 <img> 就能神不知鬼不觉地请求跨域接口。
  • 对于 POST 请求,很多例子都使用 form 提交:
    <form action="http://bank.com/transfer.do" method="POST">
      <input type="hidden" name="acct" value="MARIA" />
      <input type="hidden" name="amount" value="100000" />
      <input type="submit" value="View my pictures" />
    </form>

浏览器同源策略不能作为防范 CSRF 的方法 浏览器允许这么做,归根到底就是因为你无法用 js 直接操作获得的结果。

如何攻击

假设网站中有一个通过 Get 请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口

    <img src="http://www.domain.com/xxx?comment='attack'"/>

    res.setHeader('Set-Cookie', `username=poetry2;sameSite=strict;path=/;httpOnly;expires=${getCookieExpires()}`)

在B网站,危险网站向A网站发起请求

    <!DOCTYPE html>
    <html>
      <body>
      <!-- 利用img自动发送请求 -->
        <img src="http://localhost:8000/api/user/login" />
      </body>
    </html>

会带上A网站的cookie

    // 在A网站下发cookie的时候,加上sameSite=strict,这样B网站在发送A网站请求,不会自动带上A网站的cookie,保证了安全
    
    
    // NAME=VALUE    赋予Cookie的名称及对应值
    // expires=DATE  Cookie 的有效期
    // path=PATH     赋予Cookie的名称及对应值
    // domain=域名   作为 Cookie 适用对象的域名 (若不指定则默认为创建 Cookie 的服务器的域名) (一般不指定)
    // Secure        仅在 HTTPS 安全通信时才会发送 Cookie
    // HttpOnly      加以限制,使 Cookie 不能被 JavaScript 脚本访问
    // SameSite      Lax|Strict|None  它允许您声明该Cookie是否仅限于第一方或者同一站点上下文
    
    res.setHeader('Set-Cookie', `username=poetry;sameSite=strict;path=/;httpOnly;expires=${getCookieExpires()}`)

如何防御

  • Get 请求不对数据进行修改
  • 不让第三方网站访问到用户 Cookie
  • 阻止第三方网站请求接口
  • 请求时附带验证信息,比如验证码或者 token
  • SameSite Cookies: 只能当前域名的网站发出的http请求,携带这个Cookie。当然,由于这是新的cookie属性,在兼容性上肯定会有问题

补充(现代做法):SameSite 兼容性问题如今已基本不存在——主流浏览器自 2020 年起将未显式声明的 cookie 默认按 SameSite=Lax 处理。Lax 允许顶级导航(如点击链接的 GET)携带 cookie 但拦截跨站 POST/<img>/AJAX;Strict 最严格但会影响从外站点入的正常登录态;None 必须同时加 Secure,否则浏览器拒绝。生产环境推荐 Lax(兼顾体验)或对敏感操作用 Strict

补充(现代做法):业界主流的 CSRF 防护是 CSRF Token(同步器令牌模式):服务端为每个会话/表单生成随机 token,前端在请求头(如 X-CSRF-Token)或表单隐藏域回传,服务端校验。由于 SOP 限制,第三方页面读不到目标站页面里的 token,因而无法伪造。SPA 场景常见 Double Submit Cookie 变体。另外校验 Origin/Referer 头可作为补充。

CSRF攻击,仅仅是利用了http携带cookie的特性进行攻击的,但是攻击站点还是无法得到被攻击站点的cookie。这个和XSS不同,XSS是直接通过拿到Cookie等信息进行攻击的

在CSRF攻击中,就Cookie相关的特性:

  • http请求,会自动携带Cookie。
  • 携带的cookie,还是http请求所在域名的cookie。

CSRF怎么获取用户的登录态

攻击全称不需要获取cookie,只是在危险的网站欺骗用户去点击已登录的网站链接,利用已登录的网站的自动发送cookie达到目的。因为http请求都会带着请求目标域下的cookie的,向同一个服务器发请求时会带上浏览器保存的对于那个服务器的cookie,而不管你从哪个网站向目标网站发请求

cookie通常是不能跨域访问的,那问什么会有csrf攻击

疑问:

csrf说用户访问了A网站,然后又访问恶意网站B, B中也发送请求到A,携带A站的cookie,这样就构成了csrf。 可是cookie好像是不支持跨域的吧?

回答

  • 浏览器会依据加载的域名附带上对应域名cookie,又不是发送b站的cookie
  • 就是如果用户在a站登录了生成了授权的cookie 之类的,然后访问b站,b站故意构造请求a站的请求,如删除操作之类的,用scriptimg或者iframe之类的加载a站着个地址,浏览器会附带上a站此登录用户的授权cookie信息,这样就构成csrf,会删除掉当前用户的数据

总结

  • XSS攻击: 注入恶意代码
    • cookie 设置 httpOnly
    • 转义页面上的输入内容和输出内容
  • CSRF: 跨站请求伪造,防护:
    • get不修改数据
    • 不被第三方网站访问到用户的 cookie
    • 设置白名单,不被第三方网站请求
    • 请求校验

3 浏览器同源策略 SOP

3.1 同源

先解释何为同源:协议、域名、端口都一样,就是同源。

url同源
https://niconico.com (opens new window)基准
https://niconico.com/spirito
https://sub.niconico.com/spiritx
http://niconico.com/spiritx
https://niconico.com:8080/spiritx

3.2 限制

  • 你之所以会遇到 跨域问题 ,正是因为 SOP 的各种限制。但是具体来说限制了什么呢?
  • 如果你说 SOP 就是“限制非同源资源的获取”,这不对,最简单的例子是引用图片、css、js 文件等资源的时候就允许跨域。
  • 如果你说 SOP 就是“禁止跨域请求”,这也不对,本质上 SOP 并不是禁止跨域请求,而是在请求后拦截了请求的回应。

其实表面上 SOP 分两种情况:

  • 可以正常引用 iframe、图片等各种资源,但是 限制对其内容进行操作
  • 直接限制 ajax 请求,准确来说是限制操作 ajax 响应结果这会引起后面说到的 CSRF

但是,本质上这两条是一样的:总之,对于非同源的资源,浏览器可以“直接使用”,但是程序员和用户不可以对这些数据进行操作,杜绝某些居心不良的行为。这就是现代安全浏览器对用户的保护之一。

下面是 3 个在实际应用中会遇到的例子:

  • 使用 ajax 请求其他跨域 API,最常见的情况,前端新手噩梦
  • iframe 与父页面交流(如 DOM 或变量的获取),出现率比较低,而且解决方法也好懂
  • 对跨域图片(例如来源于 <img> )进行操作,在 canvas 操作图片的时候会遇到这个问题

如果没有了 SOP:

  • iframe 里的机密信息被肆意读取
  • 更加肆意地进行 CSRF
  • 接口被第三方滥用

3.3 绕过跨域

SOP 虽然让用户更安全,同时也会对程序员带来一定程度的麻烦,因为有时候业务上就是有跨域的需求。绕过跨域的方案由于篇幅所限,并且网上也很多相关文章,所以不在这里展开解决跨域的方案,只给出几个关键词:

对于 ajax

  • 使用 JSONP
  • 后端进行 CORS 配置
  • 后端反向代理
  • 使用 WebSocket

对于 iframe

  • 使用 location.hashwindow.name 进行信息交流
  • 使用 postMessage

补充(现代做法):生产中首选 CORSJSONP 因只支持 GET、安全性差且已可被 CORS 完全替代,新项目不应再用。开发环境跨域通常用 dev-server 代理(如 Vite server.proxy、Webpack devServer proxy),生产用 Nginx 反向代理统一同源。iframe 通信统一用 postMessage务必校验 event.originlocation.hash/window.name 属于历史 hack,不推荐。

3.4 浏览器同源策略与ajax

对于 ajax 请求,在获得数据之后你能肆意进行 js 操作。这时候虽然同源策略会阻止响应,但依然会发出请求。因为执行响应拦截的是浏览器 而不是后端程序。事实上你的请求已经发到服务器 并返回了结果,但是迫于安全策略,浏览器不允许你继续进行 js 操作 ,所以报出你熟悉的 blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

所以再强调一次,同源策略不能作为防范 CSRF 的方法

不过可以防范 CSRF 的例外 还是有的,浏览器并不是让所有请求都发送成功,上述情况仅限于简单请求 ,相关知识会在下面 CORS 一节详细解释。

4 跨域资源共享 CORS

CORS 请求决策流程:简单请求(GET/HEAD/POST + 简单头)直接发送;非简单请求先发 OPTIONS 预检,服务器返回 Access-Control-Allow-* 头,校验通过才发正式请求,否则被浏览器拦截

跨域是浏览器限制,跨域资源共享(Cross-origin resource sharing)也是服务器与浏览器协调的结果。

如果服务器设置了 CORS 相关配置,在返回浏览器的请求头会加上 Access-Control-Allow- Origin,浏览器看到这个字段的值与当前的源匹配,就会解锁跨域限制。

    HTTP/1.1 200 OK
    Date: Sun, 24 Apr 2016 12:43:39 GMT
    Server: Apache
    Access-Control-Allow-Origin: http://www.acceptmeplease.com
    Keep-Alive: timeout=2, max=100
    Connection: Keep-Alive
    Content-Type: application/xml
    Content-Length: 423

对于 CORS,请求分两种。

4.1 简单请求

  • 请求方法使用 GETPOSTHEAD
  • Content-Type 设为 application/x-www-form-urlencodedmultipart/form-datatext/plain

符合上面两个条件的都为 CORS 简单请求。简单请求都会直接发到服务器,会造成 CSRF

4.2 预检请求

不符合简单请求要求的请求都需要先发送预检请求(Preflight Request)。浏览器会在真正请求前发送 OPTION 方法的请求向服务器询问当前源是否符合 CORS 目标,验证通过后才会发送正式请求。

例如使用 application/json 传参的 POST 请求 就是非简单请求,会在预检中被拦截。

再例如使用 PUT 方法请求,也会发送预检请求。

上面提到的可以防范 CSRF 的例外 ,就是指预检请求。即使跨域成功请求预检,但真正请求并不能发出去,这就保证了 CSRF 无法成功。

补充(现代做法):预检请求的方法名是 OPTIONS(原文写成 OPTION,准确写法应为 OPTIONS)。服务端可通过 Access-Control-Max-Age 让浏览器缓存预检结果,减少重复 OPTIONS 开销。需要注意:预检只是「问一次」,它并不能阻止简单请求造成的 CSRF;真正应对 CSRF 还是要靠 CSRF Token + SameSite Cookie。

  • 与同域不同,用于跨域的 CORS 请求默认不发送 CookieHTTP 认证信息,前后端都要在配置中设定请求时带上 cookie
  • 这就是为什么在进行 CORS 请求时 axios 需要设置 withCredentials: true

下面是 node.js 的后台 koa 框架的 CORS 设置:

    /**
     * CORS middleware
     *
     * @param {Object} [options]
     *  - {String|Function(ctx)} origin `Access-Control-Allow-Origin`, default is request Origin header
     *  - {String|Array} allowMethods `Access-Control-Allow-Methods`, default is 'GET,HEAD,PUT,POST,DELETE,PATCH'
     *  - {String|Array} exposeHeaders `Access-Control-Expose-Headers`
     *  - {String|Array} allowHeaders `Access-Control-Allow-Headers`
     *  - {String|Number} maxAge `Access-Control-Max-Age` in seconds
     *  - {Boolean} credentials `Access-Control-Allow-Credentials`
     *  - {Boolean} keepHeadersOnError Add set headers to `err.header` if an error is thrown
     * @return {Function} cors middleware
     * @api public
     */

顺带一提,Access-Control-Allow-Credentials 设为 true 时,Access-Control-Allow- Origin 强制不能设为 *,为了安全,也是挺麻烦

补充(现代做法):当 Allow-Credentials: true 时,Access-Control-Allow-Origin 必须回显具体的请求 Origin(动态读取请求头里的 Origin 并校验白名单后回填),而不能用 *;同时建议带上 Vary: Origin 避免缓存把不同源的响应串味。fetch 携带 cookie 的写法是 fetch(url, { credentials: 'include' }),对应 axioswithCredentials: true

5 密码安全

加盐

对于密码存储来说,必然是不能明文存储在数据库中的,否则一旦数据库泄露,会对用户造成很大的损失。并且不建议只对密码单纯通过加密算法加密,因为存在彩虹表的关系

  • 通常需要对密码加盐,然后进行几次不同加密算法的加密
    // 加盐也就是给原密码添加字符串,增加原密码长度
    sha256(sha1(md5(salt + password + salt)))

但是加盐并不能阻止别人盗取账号,只能确保即使数据库泄露,也不会暴露用户的真实密码。一旦攻击者得到了用户的账号,可以通过暴力破解的方式破解密码。对于这种情况,通常使用验证码增加延时或者限制尝试次数的方式。并且一旦用户输入了错误的密码,也不能直接提示用户输错密码,而应该提示账号或密码错误

补充(现代做法):md5/sha1/sha256 这类通用哈希算法计算太快,配合 GPU 可以每秒尝试上亿次,靠多套快哈希叠加并不能真正抵御暴力破解。正确做法是使用专为密码设计的慢哈希算法bcryptscrypt 或首选 Argon2id,它们自带盐、可调成本因子(work factor),随硬件升级提高迭代次数即可。Node 示例:bcrypt.hash(password, 12),校验用 bcrypt.compare(input, hash)

前端加密

虽然前端加密对于安全防护来说意义不大,但是在遇到中间人攻击的情况下,可以避免明文密码被第三方获取

补充(现代做法):防中间人攻击的根本手段是全站 HTTPS(TLS),并配合 HSTS 强制浏览器只走 HTTPS。在 TLS 已经加密传输的前提下,前端再做一层密码加密收益有限,反而可能带来「前端密文等价于明文」的设计陷阱。前端加密只在特定威胁模型(如端到端加密、零知识架构)下才有真正价值。

阅读全文

Last Updated:
Contributors: leeguooooo