#

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

# XSS

XSS 是 Cross-site scripting 的缩写,即跨站脚本攻击,在 XSS 攻击中,攻击者把恶意代码注入到网站中,普通用户在打开网站时,被注入的恶意代码便会执行。需要注意的是,攻击者并不能直接攻击受害者的的电脑,而是利用受害者访问的网站上的漏洞,通过注入等方式让恶意 JS 代码在受害者的电脑上运行。

如果你的网站被 XSS 攻击成功了,那么攻击者可能会做什么事呢?

  • 窃取用户敏感信息,如 cookie,身份令牌等被盗取
  • 记录键盘信息,通过在密码框上绑定 input 事件来监听用户输入,然后把密码发送到攻击者的服务器
  • 生成虚假表单,诱导用户输入敏感信息

# XSS 攻击的分类

# 持久型 XSS (Persistent XSS)

存储型 XSS 应该是最常见的 XSS 攻击了,存储型 xss 一出现在网站留言板,评论处,个人资料处,等需要用户可以对网站写入数据的地方。比如一个论坛评论处由于对用户输入过滤不严格,导致攻击者在写入一段窃取 cookie 的恶意 JavaScript 代码到评论处,这段恶意代码会写入数据库,当其他用户浏览这个写入代码的页面时,网站从数据库中读取恶意代码显示到网页中被浏览器执行,导致用户 cookie 被窃取,攻击者无需受害者密码即可登录账户。

举个例子

这是一个输入页面

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

服务器保存输入,然后在其他用户访问时把输入嵌入到页面中返回

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. 恶意代码在普通用户的浏览器中执行

攻击者的网站

<div>这是攻击者的网站</div>
<a href="http://localhost:5000/?s=<script>alert('你被攻击啦, 下次不要乱点链接了哦')<script/>">劲爆大片点这里</a>

这是正常网站的服务器

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

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

服务器

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}`);
});

恶意链接

<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

res.cookie("token", val, {
	httpOnly : true
});

设置 HttpOnly 不能完全阻止 XSS,它只是防止了用户的用户凭证被盗取,我们还需要其他的方式

# 不要轻易使用 innerHTML

如果不是万不得已,不要使用 innerHTML ,因为 innerHTML 会把里面的内容当成 DOM 来解析,如果用户的输入中有 script 标签,那么 script 标签里的代码也会执行,所以尽可能使用 innerText 进行开发。

# 在输入输出时进行编码

如果你万不得已需要用到 innerHTML , 那你一定要对用户的输入进行编码,编码可以让用户的输入在解析时按字符串来解析

简单的编码规则:

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

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

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 来编写内容,但是要对一些危险的操作进行处理,比如用户写了以下代码

<img src="0" alt="" onerror="alert(1)"/>

这个代码如果原封不动展示到网页上,就会触发 XSS 攻击,我们要做的就是对这个输入进行处理。这里我们使用一个已经比较成熟的库 js-xss 来处理

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 表单

攻击者的网站

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

服务器

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}`);
});

正常的网站

<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 到请求中

<a href="http://localhost:8080/article/save?content=CSRF">劲爆大片</a>

# 图片加载

img 标签的 src 属性同理,也会携带 cookie 到请求中

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

# 二次验证

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

# 表单随机数

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