序
前端安全应该是老生常谈的话题了,毕竟世界上没有绝对安全的系统,我们工程师能做的只是让入侵变得更难,这篇文章会介绍前端工程师需要注意的两种攻击,XSS和CSRF攻击。
XSS
XSS是Cross-site scripting的缩写,即跨站脚本攻击,在XSS攻击中,攻击者把恶意代码注入到网站中,普通用户在打开网站时,被注入的恶意代码便会执行。需要注意的是,攻击者并不能直接攻击受害者的的电脑,而是利用受害者访问的网站上的漏洞,通过注入等方式让恶意JS代码在受害者的电脑上运行。
如果你的网站被XSS攻击成功了,那么攻击者可能会做什么事呢?
- 窃取用户敏感信息,如cookie,身份令牌等被盗取
 
- 记录键盘信息,通过在密码框上绑定input事件来监听用户输入,然后把密码发送到攻击者的服务器
 
- 生成虚假表单,诱导用户输入敏感信息
 
XSS攻击的分类
持久型XSS(Persistent XSS)
存储型XSS应该是最常见的XSS攻击了,存储型xss一出现在网站留言板,评论处,个人资料处,等需要用户可以对网站写入数据的地方。比如一个论坛评论处由于对用户输入过滤不严格,导致攻击者在写入一段窃取cookie的恶意JavaScript代码到评论处,这段恶意代码会写入数据库,当其他用户浏览这个写入代码的页面时,网站从数据库中读取恶意代码显示到网页中被浏览器执行,导致用户cookie被窃取,攻击者无需受害者密码即可登录账户。
举个例子
这是一个输入页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
   | <textarea id="input" cols="30" rows="10"></textarea> <button id="btn">Submit</button>
  <script>     const input = document.getElementById('input');     const btn = document.getElementById('btn');
      let val;
      input.addEventListener('change', (e) => {         val = e.target.value;     }, false);
      btn.addEventListener('click', (e) => {         fetch('http://localhost:5000/save', {             method: 'POST',             body: val         });     }, false); </script>
   | 
 
服务器保存输入,然后在其他用户访问时把输入嵌入到页面中返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
   | const http = require('http'); let userInput = ''; function handleRequest(req, res) {     const method = req.method;     res.setHeader('Access-Control-Allow-Origin', '*');     res.setHeader('Access-Control-Allow-Headers', 'Content-Type')     if (method === 'POST' && req.url === '/save') {         let body = '';         req.on('data', chunk => {             body += chunk;         });         req.on('end', () => {             if (body) {                 userInput = body;             }             res.end();         });     } else {         res.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});         res.write(             `<html><body><div id="app">${userInput}</div></body></html>`         );         res.end();     } }
  const server = new http.Server(); server.listen(5000); server.on('request', handleRequest);
  | 
 
我们提交一段JS脚本

当再访问 http://localhost:5000 时,会出现下面的弹框

这时,只要任意一个用户访问网站,恶意代码就会在用户的浏览器中执行。
反射型XSS(Reflected XSS)
反射型XSS通常是使用URL的形式来攻击的,过程如下
- 攻击者制作一个包含恶意代码的URL
 
- 普通用户点击了攻击者制作的URL
 
- 该服务器在响应中包含来自URL的恶意代码,而且没有转义
 
- 恶意代码在普通用户的浏览器中执行
 
攻击者的网站
1 2
   | <div>这是攻击者的网站</div> <a href="http://localhost:5000/?s=<script>alert('你被攻击啦, 下次不要乱点链接了哦')<script/>">劲爆大片点这里</a>
   | 
 
这是正常网站的服务器
1 2 3 4 5 6 7 8 9 10 11 12
   | const express = require("express"); const app = express(); const port = 5000;
  app.get("*", (req, res) => {     res.write(`<html><body><div>you search key word : ${req.query.s}</div></body></html>`); 	res.end(); })
  app.listen(port, () => {     console.log(`server listen on ${port}`); });
  | 
 
开启服务器访问http://localhost:5000/?s=abc 的效果是这样的

但是如果点了攻击者的诱导链接,恶意JS就会执行

基于DOM的XSS(DOM-based XSS)
基于DOM的XSS和上面两种很类似,只是这次背锅的不是不加验证的服务器而是不加验证的合法JS了,攻击的过程如下。
- 攻击者制作一个包含恶意代码的URL
 
- 普通用户点击了攻击者制作的URL
 
- 服务器收到请求,但响应中不包含恶意字符串。
 
- 普通用户的浏览器执行合法脚本,将恶意脚本插入页面。
 
- 恶意代码在普通用户的浏览器中执行
 
举个例子
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
   | <body>     <div id="app">你搜索的关键字是 : </div>     <script>         const app = document.querySelector("#app");         let query = parseQueryString(location.search);         app.innerHTML = `你搜索的关键字是 : ${query.s}`
 
          function parseQueryString(url) {             let obj = {};             let keyValue = [];             let key = "", value = "";             let paraString = url.substring(url.indexOf("?") + 1, url.length).split("&");             for (let i in paraString) {                 keyValue = paraString[i].split("=");                 key = keyValue[0];                 value = keyValue[1];                 obj[key] = value;             }             return obj;         }     </script> </body>
   | 
 
服务器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
   | const express = require("express"); const http = require("http"); const app = express(); const port = 5000; const path = require("path"); const staticRoot = path.resolve(__dirname, "public");
  app.use(express.static(staticRoot, {     index : 'index.html' }));
  app.listen(port, () => {     console.log(`server listen on ${port}`); });
 
  | 
 
恶意链接
1 2 3
   | <div>这是攻击者的网站</div> <a href="http://localhost:5000/?s=<script>alert(`你被攻击啦, 下次不要乱点链接了哦`)</script>">劲爆大片点这里</a>
 
   | 
 

恶意JS同样会执行
防御XSS
设置cookie的HttpOnly
HttpOnly最早是由微软提出,并在IE 6中实现的,至今已经逐渐成为一个标准,各大浏览器都支持此标准。具体含义就是,如果某个Cookie带有HttpOnly属性,那么这一条Cookie 将被禁止读取,也就是说,JavaScript读取不到此条 Cookie,不过在与服务端交互的时候,Http Request中仍然会带上这个Cookie。
服务器在设置网站cookie时设置cookie的HttpOnly,我们使用cookie-parse这个库可以很方便的设置cookie
1 2 3
   | res.cookie("token", val, { 	httpOnly : true });
  | 
 
设置HttpOnly不能完全阻止XSS,它只是防止了用户的用户凭证被盗取,我们还需要其他的方式
不要轻易使用innerHTML
如果不是万不得已,不要使用innerHTML,因为innerHTML会把里面的内容当成DOM来解析, 如果用户的输入中有script标签,那么script标签里的代码也会执行,所以尽可能使用innerText进行开发。
在输入输出时进行编码
如果你万不得已需要用到innerHTML, 那你一定要对用户的输入进行编码,编码可以让用户的输入在解析时按字符串来解析
简单的编码规则:
| 特殊字符 | 
实体编码 | 
| & | 
& ; | 
| < | 
< ; | 
| > | 
> ; | 
| “ | 
" ; | 
| ‘ | 
' ; | 
| / | 
/ ; | 
另外还有更详细的编码规则, 可以直接使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
   | const HtmlEncode = (str) => {          const hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];          const preescape = str;     let escaped = "";     for (let i = 0; i < preescape.length; i++) {                  let p = preescape.charAt(i);                  escaped = escaped + escapeCharx(p);     }
      return escaped;               function escapeCharx(original) {                  let found = true;                  const thechar = original.charCodeAt(0);         switch (thechar) {             case 10: return "<br/>"; break;              case 32: return " "; break;              case 34: return """; break; // "             case 38: return "&"; break;              case 39: return "'"; break;              case 47: return "/"; break;              case 60: return "<"; break;              case 62: return ">"; break;              case 198: return "Æ"; break;              case 193: return "Á"; break;              case 194: return "Â"; break;              case 192: return "À"; break;              case 197: return "Å"; break;              case 195: return "Ã"; break;              case 196: return "Ä"; break;              case 199: return "Ç"; break;              case 208: return "Ð"; break;              case 201: return "É"; break;              case 202: return "Ê"; break;             case 200: return "È"; break;             case 203: return "Ë"; break;             case 205: return "Í"; break;             case 206: return "Î"; break;             case 204: return "Ì"; break;             case 207: return "Ï"; break;             case 209: return "Ñ"; break;             case 211: return "Ó"; break;             case 212: return "Ô"; break;             case 210: return "Ò"; break;             case 216: return "Ø"; break;             case 213: return "Õ"; break;             case 214: return "Ö"; break;             case 222: return "Þ"; break;             case 218: return "Ú"; break;             case 219: return "Û"; break;             case 217: return "Ù"; break;             case 220: return "Ü"; break;             case 221: return "Ý"; break;             case 225: return "á"; break;             case 226: return "â"; break;             case 230: return "æ"; break;             case 224: return "à"; break;             case 229: return "å"; break;             case 227: return "ã"; break;             case 228: return "ä"; break;             case 231: return "ç"; break;             case 233: return "é"; break;             case 234: return "ê"; break;             case 232: return "è"; break;             case 240: return "ð"; break;             case 235: return "ë"; break;             case 237: return "í"; break;             case 238: return "î"; break;             case 236: return "ì"; break;             case 239: return "ï"; break;             case 241: return "ñ"; break;             case 243: return "ó"; break;             case 244: return "ô"; break;             case 242: return "ò"; break;             case 248: return "ø"; break;             case 245: return "õ"; break;             case 246: return "ö"; break;             case 223: return "ß"; break;             case 254: return "þ"; break;             case 250: return "ú"; break;             case 251: return "û"; break;             case 249: return "ù"; break;             case 252: return "ü"; break;             case 253: return "ý"; break;             case 255: return "ÿ"; break;             case 162: return "¢"; break;             case '\r': break;             default: found = false; break;         }         if (!found) {                          if (thechar > 127) {                 let c = thechar;                 let a4 = c % 16;                 c = Math.floor(c / 16);                 let a3 = c % 16;                 c = Math.floor(c / 16);                 let a2 = c % 16;                 c = Math.floor(c / 16);                 let a1 = c % 16;                 return "&#x" + hex[a1] + hex[a2] + hex[a3] + hex[a4] + ";";             } else {                 return original;             }         }     } }
 
  | 
 
在输入输出时验证输入
你可能会觉得有些疑惑,既然上一步已经对HTML做了编码,那为什么还要进行验证呢,因为有些使用,你不能单纯的把用户的输入当成字符串,比如说你做了一个富文本编辑器,用户可以使用HTML来编写内容,但是要对一些危险的操作进行处理,比如用户写了以下代码
1
   | <img src="0" alt="" onerror="alert(1)"/>
   | 
 
这个代码如果原封不动展示到网页上,就会触发XSS攻击,我们要做的就是对这个输入进行处理。这里我们使用一个已经比较成熟的库js-xss来处理
1 2 3 4 5 6 7 8 9 10 11
   | let xss = require("xss");
  var options = {   whiteList: {     a: ["href", "title", "target"], 	img : ["src",  "alt"]   } };
  let html = xss('<img src="0" alt="" onerror="alert(1)"/>', options);
 
  | 
 
使用whiteList(白名单)配置可以让用户输入的html中尽可能的只有安全字段,从而达到过滤恶意代码的效果。
CSP(Content Security Policy)
内容安全策略 (CSP) 是一个额外的安全层,用于检测并削弱某些特定类型的攻击,包括跨站脚本 (XSS) 和数据注入攻击等。无论是数据盗取、网站内容污染还是散发恶意软件,这些攻击都是主要的手段。
这个比较复杂,我就不具体讲解了,需要的朋友可以看看下面几个教程
Content Security Policy 入门教程
Content Security Policy (CSP) 介绍
CSRF
CSRF是什么
CSRF是Cross Site Request Forgery的简称,中文名是跨站请求伪造,这种攻击是通过冒用已登录用户的身份,模拟正常用户的操作来完成的

它的原理如下:
- 用户访问正常站点,登录后,获取到了正常站点的令牌,以cookie的形式保存
 

- 用户访问恶意站点,恶意站点通过某种形式去请求了正常站点(请求伪造),迫使正常用户把令牌传递到正常站点,完成攻击
 

常见的CSRF攻击方式
攻击者的网站
1 2 3 4 5 6 7 8 9 10 11
   | <body>     <form method="post" id="form" action="http://localhost:5000/article">         <input value="CSRF攻击" name="content"/>     </form> </body>
  <script>     let form = document.querySelector("#form");     form.submit();     history.back(); </script>
   | 
 
服务器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
   | const express = require("express"); const app = express(); const port = 5000; const path = require("path"); const staticRoot = path.resolve(__dirname, "public"); const cookieParser = require("cookie-parser");
  app.use(cookieParser()); app.use(express.static(staticRoot));
  app.post("/article", (req, res) => {     console.log(req.cookies);     res.write(JSON.stringify({         name : 'sena'     }));     res.end(); });
  app.listen(port, () => {     console.log(`server listen on ${port}`); });
  | 
 
正常的网站
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
   | <label>     <textarea id="article"></textarea> </label>     <button id="button">提交</button>
  <script>     let article = document.querySelector("#article");     let button = document.querySelector("#button");     let url = "http://localhost:5000/article";     button.onclick = function () {         const data = article.value;         console.log(data);         fetch(url, {             method: 'POST',             body : data         }).then(res => res.json()).then((res) => {             console.log(res);         })     } </script>
   | 
 
如果在正常的网站里,用户保存了身份信息在cookie里,然后再访问攻击者的网站,cookie就随着请求一起携带到服务器,form表单一般会用于post攻击

a标签
如果目标网站允许get 请求,可通过超链接攻击,点击时也会携带cookie到请求中
1
   | <a href="http://localhost:8080/article/save?content=CSRF">劲爆大片</a>
   | 
 
图片加载
img标签的src属性同理,也会携带cookie到请求中
1
   | <img src="http://localhost:8080/article/save?content=CSRF" />
   | 
 
防御CSRF
设置cookie的SameSite
现在很多浏览器都支持禁止跨域附带的cookie,只需要把cookie设置的SameSite设置为Strict即可
SameSite有以下取值:
- Strict:严格,所有跨站请求都不附带cookie,有时会导致用户体验不好
 
- Lax:宽松,所有跨站的超链接、GET请求的表单、预加载连接时会发送cookie,其他情况不发送
 
- None:无限制
 
这种方法非常简单,极其有效,但前提条件是:用户不能使用太旧的浏览器
验证referer和Origin
页面中的二次请求都会附带referer或Origin请求头,向服务器表示该请求来自于哪个源或页面,服务器可以通过这个头进行验证,如果不是可以信任的网站就不处理这个请求
但某些浏览器的referer是可以被用户禁止的,尽管这种情况极少
使用非cookie令牌
这种做法是要求身份验证的token放在请求头中或者其他地方,在每次请求时用JS添加
二次验证
对敏感操作进行二次验证,比如各种各样的验证码,缺点是正常用户使用时会觉得比较繁琐
表单随机数
这种做法是服务端渲染时,生成一个一次性的随机数,客户端提交时要提交这个随机数,然后服务器端进行对比