背景
vue 项目中,需要实现搜索关键字高亮的效果,测试时,发现了一个严重的安全问题--XSS(跨站脚本攻击)。 接下来详细描述:
实现思路:通过正则匹配将关键字用 span 标签包裹,有个高亮的样式类名,通过 v-html 渲染。
看效果似乎很完美
问题及原因
结果在测试过程中,发现了很大的问题
-
- 如果搜索关键字中存在特殊符号, 如 #、 (、) 的时候有问题
-
- 如果昵称中存在脚本,脚本被解析了
下面分析引起问题的原因
其实引起以上两个问题的原因都很容易想到
第二个问题的原因最明显, 是因为使用 v-html, 会将标签进行解析。
第一个问题的原因也比较容易发现:在匹配关键字的时候使用的是正则, 而有些特殊字符在正则中是有其它含义的。
具体实现
最初的实现代码, 这里使用原生js 模拟, css部分省略
html
<div class="container">
<div class="header">
<input type="text" class="ipt" />
<button class="btn">搜索</button>
</div>
<div class="title">搜索结果区域</div>
<div class="search-res"></div>
</div>
js
const data = [
{
nick_name: 'abcdef<script>alert(999)</script>xxx123',
},
{
nick_name: '又菜又爱玩123abc',
},
{
nick_name: '不知道起abc什么名字yyy123',
},
{
nick_name: 'abc胡123abc888呼呼呼#',
},
{
nick_name: '只会法师的上单哦哦哦3123abc',
},
]
function $(selector) {
return document.querySelector(selector);
}
const ipt = $('.ipt');
const btn = $('.btn');
const content = $('.search-res');
let result = [];
function init() {
content.innerText = '暂无结果';
btn.onclick = search;
}
function search() {
const keyword = ipt.value;
result = data.filter((it) =>
it.nick_name.toLowerCase().includes(keyword.toLowerCase())
);
result = result.map((it) => {
// 使用正则匹配替换关键字为 span
const name = it.nick_name.replaceAll(new RegExp(keyword, 'ig'), (val) => {
return `<span class="color-bg">${val}</span>`;
});
return `<div title="${it.nick_name}" class="item">昵称: ${name}</div>`;
});
// 使用 innerHTML 进行结构渲染
content.innerHTML = result.join('');
}
解决方案
既然上面的方案有问题,只能另想它法了。
如果能将关键字(也就是要高亮的字符)和普通字符(不需要高亮)区分开就好办了。
那就试一试呗,看看官方提供的方法中有没有能实现想要的结果,找一圈,发现没有,还是老老实实写吧。
function formatNickName(nick_name, keyword) {
const lower_name = nick_name.toLowerCase();
const lower_word = keyword.toLowerCase();
const arr = [];
const len = keyword.length;
const sliceStr = (initName, lowerName, key) => {
const index = lowerName.indexOf(key);
if (index === -1) {
arr.push({ isKeyword: false, value: initName });
return arr;
}
const n1 = initName.substring(0, index); // 不是关键字
const k1 = initName.substring(index, index + len); // 是关键字
arr.push({ isKeyword: false, value: n1 }, { isKeyword: true, value: k1 });
const nextInitName = initName.substring(index + len);
const nextLowerName = lowerName.substring(index + len);
sliceStr(nextInitName, nextLowerName, key);
};
sliceStr(nick_name, lower_name, lower_word);
return arr.filter((it) => it.value);
}
function search() {
content.innerText = '';
const keyword = ipt.value;
result = data.filter((it) =>
it.nick_name.toLowerCase().includes(keyword.toLowerCase())
);
result.forEach((it) => {
const div = document.createElement('div');
div.title = it.nick_name;
div.className = 'item';
const names = formatNickName(it.nick_name, keyword);
names.forEach((it) => {
const span = document.createElement('span');
span.innerText = it.value;
if (it.isKeyword) {
span.className = 'color-bg';
}
div.appendChild(span);
});
content.appendChild(div);
});
}
效果