序
前端安全应该是老生常谈的话题了,毕竟世界上没有绝对安全的系统,我们工程师能做的只是让入侵变得更难,这篇文章会介绍前端工程师需要注意的两种攻击,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添加
二次验证
对敏感操作进行二次验证,比如各种各样的验证码,缺点是正常用户使用时会觉得比较繁琐
表单随机数
这种做法是服务端渲染时,生成一个一次性的随机数,客户端提交时要提交这个随机数,然后服务器端进行对比