前端安全应该是老生常谈的话题了,毕竟世界上没有绝对安全的系统,我们工程师能做的只是让入侵变得更难,这篇文章会介绍前端工程师需要注意的两种攻击,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脚本

file

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

file

这时,只要任意一个用户访问网站,恶意代码就会在用户的浏览器中执行。

反射型XSS(Reflected XSS)

反射型XSS通常是使用URL的形式来攻击的,过程如下

  1. 攻击者制作一个包含恶意代码的URL
  2. 普通用户点击了攻击者制作的URL
  3. 该服务器在响应中包含来自URL的恶意代码,而且没有转义
  4. 恶意代码在普通用户的浏览器中执行

攻击者的网站

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 的效果是这样的
file

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

基于DOM的XSS(DOM-based XSS)

基于DOM的XSS和上面两种很类似,只是这次背锅的不是不加验证的服务器而是不加验证的合法JS了,攻击的过程如下。

  1. 攻击者制作一个包含恶意代码的URL
  2. 普通用户点击了攻击者制作的URL
  3. 服务器收到请求,但响应中不包含恶意字符串。
  4. 普通用户的浏览器执行合法脚本,将恶意脚本插入页面。
  5. 恶意代码在普通用户的浏览器中执行

举个例子

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>

file

恶意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, 那你一定要对用户的输入进行编码,编码可以让用户的输入在解析时按字符串来解析

简单的编码规则:

特殊字符 实体编码
& &amp ;
< &lt ;
> &gt ;
&quot ;
&#x27 ;
/ &#x2F ;

另外还有更详细的编码规则, 可以直接使用

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) => {
// 设置 16 进制编码,方便拼接
const hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'];
// 赋值需要转换的HTML
const preescape = str;
let escaped = "";
for (let i = 0; i < preescape.length; i++) {
// 获取每个位置上的字符
let p = preescape.charAt(i);
// 重新编码组装
escaped = escaped + escapeCharx(p);
}

return escaped;
// HTMLEncode 主要函数
// original 为每次循环出来的字符
function escapeCharx(original) {
// 默认查到这个字符编码
let found = true;
// charCodeAt 获取 16 进制字符编码
const thechar = original.charCodeAt(0);
switch (thechar) {
case 10: return "<br/>"; break; // 新的一行
case 32: return " "; break; // space
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) {
// 如果和上面内容不匹配且字符编码大于127的话,用unicode(非常严格模式)
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的简称,中文名是跨站请求伪造,这种攻击是通过冒用已登录用户的身份,模拟正常用户的操作来完成的

file

它的原理如下:

  1. 用户访问正常站点,登录后,获取到了正常站点的令牌,以cookie的形式保存

file

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

file

常见的CSRF攻击方式

form表单

攻击者的网站

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攻击

file

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添加

二次验证

对敏感操作进行二次验证,比如各种各样的验证码,缺点是正常用户使用时会觉得比较繁琐

表单随机数

这种做法是服务端渲染时,生成一个一次性的随机数,客户端提交时要提交这个随机数,然后服务器端进行对比