搜索关键字高亮引发的XSS攻击

134 阅读2分钟

背景

vue 项目中,需要实现搜索关键字高亮的效果,测试时,发现了一个严重的安全问题--XSS(跨站脚本攻击)。 接下来详细描述:

实现思路:通过正则匹配将关键字用 span 标签包裹,有个高亮的样式类名,通过 v-html 渲染。

看效果似乎很完美

image.png

问题及原因

结果在测试过程中,发现了很大的问题

    1. 如果搜索关键字中存在特殊符号, 如 #、 (、) 的时候有问题 image.png
    1. 如果昵称中存在脚本,脚本被解析了 image.png

下面分析引起问题的原因

其实引起以上两个问题的原因都很容易想到

第二个问题的原因最明显, 是因为使用 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);
  });
}

效果

image.png

image.png