Vue 中实现隐藏字符检测与复制组件

背景介绍

在现代 Web 应用中,特别是处理用户输入和数据展示的系统中,我们经常会遇到一个看似无害但实际危险的安全问题:隐藏字符攻击

什么是隐藏字符攻击?

隐藏字符攻击是指恶意用户利用 Unicode 中的不可见字符(如零宽度空格、不可见分隔符等)来绕过系统检测或制造视觉欺骗的攻击手段。例如:

  • 用户输入看似为 "NetEase",但实际包含了 U+2062(不可见乘号)和 U+2063(不可见分隔符)
  • 实际字符串:"Ne⁢⁢⁢⁣⁢⁣⁢tEa⁢⁢⁢⁣⁢⁣⁢se⁢..."
  • 这可能导致系统误判、绕过过滤规则或造成数据污染

业务痛点

在我们的邮件安全审计系统中,经常需要处理各种文本数据:

  1. 邮件主题和内容可能包含恶意隐藏字符
  2. 发件人地址使用隐藏字符进行伪装
  3. URL 和域名通过隐藏字符绕过黑名单检测
  4. 用户无法直观识别这些隐藏字符的存在

设计思路

核心理念

我们的解决方案基于以下设计原则:

  1. 可视化检测:让不可见变为可见
  2. 便捷操作:一键复制原始和标记版本
  3. 组件化复用:统一的检测逻辑,多处复用
  4. 渐进增强:不影响原有功能,额外提供安全保护

技术架构

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│  工具类层        │    │   组件层          │    │   业务层         │
│ invisibleChar   │────│ InvisibleChar    │────│  各种页面组件    │
│ Utils.js        │    │ Text.vue         │    │                │
└─────────────────┘    └──────────────────┘    └─────────────────┘

核心实现

1. 隐藏字符检测工具类

首先,我们创建了一个完整的隐藏字符检测工具库:

// src/utils/invisibleCharUtils.js

/**
 * 判断字符是否为隐藏字符
 * @param {number} codePoint - Unicode 代码点
 * @returns {boolean} 是否为隐藏字符
 */
export function isInvisibleChar(codePoint) {
  // 不可见字符的精确集合
  const INVISIBLE_SET = new Set([
    0x200B, // ZERO WIDTH SPACE
    0x200C, // ZERO WIDTH NON-JOINER
    0x200D, // ZERO WIDTH JOINER
    0x2060, // WORD JOINER
    0xFEFF, // ZERO WIDTH NO-BREAK SPACE
    0x200E, // LEFT-TO-RIGHT MARK
    0x200F, // RIGHT-TO-LEFT MARK
    0x2061, // FUNCTION APPLICATION
    0x2062, // INVISIBLE TIMES (⁢)
    0x2063, // INVISIBLE SEPARATOR (⁣)
    0x2064, // INVISIBLE PLUS
    0x034F, // COMBINING GRAPHEME JOINER
    // ... 更多隐藏字符
  ]);
  
  if (INVISIBLE_SET.has(codePoint)) return true;
  
  // 范围检查 - 覆盖更全面的隐藏字符范围
  if ((codePoint >= 0x202A && codePoint <= 0x202E) || // 双向文本控制
      (codePoint >= 0x2066 && codePoint <= 0x2069) || // 双向文本隔离
      (codePoint >= 0x206A && codePoint <= 0x206F) || // 格式字符
      (codePoint >= 0xFE00 && codePoint <= 0xFE0F) || // 变体选择符
      (codePoint >= 0xE0000 && codePoint <= 0xE007F)) { // 标签字符
    return true;
  }
  
  return false;
}

2. 字符串检测与标记

/**
 * 检测字符串是否包含隐藏字符
 */
export function containsInvisibles(str) {
  if (!str) return false;
  
  for (let i = 0; i < str.length; i++) {
    const cp = str.codePointAt(i);
    const len = cp > 0xFFFF ? 2 : 1; // 处理4字节Unicode字符
    if (isInvisibleChar(cp)) return true;
    i += len - 1;
  }
  return false;
}

/**
 * 将隐藏字符标记为可见形式
 */
export function markInvisibleChars(text) {
  if (!text) return '';
  
  let html = '';
  for (let i = 0; i < text.length; i++) {
    const cp = text.codePointAt(i);
    const len = cp > 0xFFFF ? 2 : 1;
    
    if (isInvisibleChar(cp)) {
      const code = cp.toString(16).toUpperCase().padStart(4, '0');
      html += `<span class="invisible-char" title="U+${code}">[U+${code}]</span>`;
    } else {
      // HTML转义处理
      const char = text.substr(i, len);
      html += char.replace(/&/g, '&amp;')
                 .replace(/</g, '&lt;')
                 .replace(/>/g, '&gt;');
    }
    i += len - 1;
  }
  return html;
}

3. 通用复制功能

/**
 * 复制字符串到剪贴板(支持现代浏览器和降级方案)
 */
export async function copyToClipboard(text, successCallback, errorCallback) {
  try {
    if (!text) return false;
    
    // 优先使用现代 Clipboard API
    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(String(text));
      successCallback && successCallback('内容已复制到剪贴板');
      return true;
    } else {
      // 降级方案:使用传统 execCommand
      const textArea = document.createElement('textarea');
      textArea.value = String(text);
      textArea.style.position = 'fixed';
      textArea.style.left = '-9999px';
      document.body.appendChild(textArea);
      textArea.select();
      
      const successful = document.execCommand('copy');
      document.body.removeChild(textArea);
      
      if (successful) {
        successCallback && successCallback('内容已复制到剪贴板');
        return true;
      }
    }
  } catch (err) {
    errorCallback && errorCallback('复制失败,请手动复制');
    return false;
  }
}

4. Vue 组件封装

基于工具类,我们创建了一个通用的 Vue 组件:

<!-- src/components/InvisibleCharText.vue -->
<template>
  <span class="invisible-char-text-container">
    <!-- 原始文本显示 -->
    <span v-text="text" 
          :style="textStyle"
          :class="textClass"></span>
    
    <!-- 隐藏字符警告和操作区 -->
    <span v-if="hasInvisibleChars" class="invisible-flag">
      ⚠️ 包含隐藏字符
      <span class="copy-icon original-copy" 
            @click.stop="copyOriginalText" 
            title="复制原始字符串">📋</span>
      <span class="copy-icon display-copy" 
            @click.stop="copyDisplayText" 
            title="复制标记后的字符串">🔖</span>
    </span>
  </span>
</template>

<script>
import { containsInvisibles, markInvisibleChars, copyToClipboard } from '@/utils/invisibleCharUtils.js'

export default {
  name: 'InvisibleCharText',
  props: {
    text: {
      type: [String, Number],
      default: ''
    },
    textClass: {
      type: String,
      default: ''
    },
    textStyle: {
      type: [String, Object],
      default: () => ({
        'white-space': 'pre-wrap',
        'font-family': 'monospace'
      })
    },
    showWarning: {
      type: Boolean,
      default: true
    }
  },
  computed: {
    hasInvisibleChars() {
      if (!this.text || !this.showWarning) return false;
      return containsInvisibles(String(this.text));
    },
    displayText() {
      if (!this.text) return '';
      return markInvisibleChars(String(this.text));
    }
  },
  methods: {
    async copyOriginalText() {
      await copyToClipboard(
        this.text,
        (msg) => this.$message && this.$message.success(msg || '原始字符串已复制'),
        (msg) => this.$message && this.$message.error(msg || '复制失败')
      );
    },
    async copyDisplayText() {
      if (!this.displayText) return;
      
      // HTML转纯文本,保留标记信息
      const tempDiv = document.createElement('div');
      tempDiv.innerHTML = this.displayText;
      const plainText = tempDiv.textContent || tempDiv.innerText || '';
      
      await copyToClipboard(
        plainText,
        (msg) => this.$message && this.$message.success(msg || '标记版本已复制'),
        (msg) => this.$message && this.$message.error(msg || '复制失败')
      );
    }
  }
}
</script>

功能特性

🔍 智能检测

  • 全面覆盖:支持 200 + 种隐藏字符检测
  • 精确识别:使用 Unicode 代码点级别检测
  • 高性能:O (n) 时间复杂度,适合大文本处理

👁️ 可视化展示

  • 警告提示:⚠️ 图标直观显示问题
  • 标记显示[U+2062] 格式展示隐藏字符
  • 样式定制:支持自定义文本样式和类名

📋 便捷操作

  • 双重复制
    • 📋 复制原始字符串(包含隐藏字符)
    • 🔖 复制标记版本(便于分析)
  • 兼容性:支持现代浏览器 Clipboard API 和传统方案
  • 用户反馈:成功 / 失败消息提示

使用指南

基础用法

<template>
  <div>
    <!-- 最简单的用法 -->
    <InvisibleCharText :text="userInput" />
    
    <!-- 带样式定制 -->
    <InvisibleCharText 
      :text="emailSubject"
      textClass="email-subject-style"
      :textStyle="{ fontSize: '14px', color: '#333' }" />
    
    <!-- 可控制警告显示 -->
    <InvisibleCharText 
      :text="content"
      :showWarning="enableSecurityCheck" />
  </div>
</template>

<script>
import InvisibleCharText from '@/components/InvisibleCharText.vue'

export default {
  components: {
    InvisibleCharText
  },
  data() {
    return {
      userInput: 'Ne⁢⁢⁢⁣⁢⁣⁢tEa⁢⁢⁢⁣⁢⁣⁢se', // 包含隐藏字符
      emailSubject: 'Normal text',
      enableSecurityCheck: true
    }
  }
}
</script>

在表格中使用

<template>
  <el-table :data="mailList">
    <el-table-column label="发件人">
      <template slot-scope="scope">
        <InvisibleCharText 
          :text="scope.row.sender"
          textClass="sender-cell" />
      </template>
    </el-table-column>
  </el-table>
</template>

Props 参数

参数 类型 默认值 说明
text String/Number '' 要检测的文本内容
textClass String '' 文本的 CSS 类名
textStyle String/Object {white-space: 'pre-wrap', font-family: 'monospace'} 文本样式
showWarning Boolean true 是否显示隐藏字符警告

实际应用效果

检测效果演示

输入文本"Ne⁢⁢⁢⁣⁢⁣⁢tEa⁢⁢⁢⁣⁢⁣⁢se"

显示效果

Ne⁢⁢⁢⁣⁢⁣⁢tEa⁢⁢⁢⁣⁢⁣⁢se ⚠️ 包含隐藏字符 📋 🔖

复制结果

  • 点击 📋:复制原始字符串(含隐藏字符)
  • 点击 🔖:复制 "Ne[U+2062][U+2062][U+2062][U+2063][U+2062][U+2063][U+2062]tEa[U+2062][U+2062][U+2062][U+2063][U+2062][U+2063][U+2062]se"

业务场景应用

  1. 邮件审计系统

    • 检测恶意邮件主题中的隐藏字符
    • 识别伪造的发件人地址
  2. 用户输入验证

    • 防止表单提交中的隐藏字符攻击
    • 保护数据库免受污染
  3. 内容管理系统

    • 检测文章标题和内容中的异常字符
    • 维护内容质量和安全性

技术亮点

🚀 性能优化

  • 懒计算:使用 Vue computed 属性,避免重复检测
  • 事件优化@click.stop 防止事件冒泡
  • 内存管理:及时清理临时 DOM 元素

🛡️ 安全考虑

  • XSS 防护:对输出内容进行 HTML 转义
  • 输入验证:严格的类型检查和边界处理
  • 降级兼容:多层级的功能降级策略

🔧 工程化

  • 模块化设计:工具类与组件分离
  • TypeScript 友好:完整的参数类型定义
  • 测试覆盖:覆盖核心检测逻辑的单元测试

扩展与优化

未来规划

  1. 检测算法优化

    • 支持更多 Unicode 字符集
    • 增加机器学习辅助检测
  2. 功能增强

    • 支持批量文本检测
    • 添加字符统计和分析功能
  3. 用户体验

    • 支持快捷键操作
    • 添加检测结果导出功能

自定义扩展

// 扩展检测规则
export function addCustomInvisibleChars(codePoints) {
  INVISIBLE_SET = new Set([...INVISIBLE_SET, ...codePoints]);
}

// 自定义标记样式
export function setCustomMarkStyle(styleFunction) {
  customMarkFunction = styleFunction;
}

总结

通过 invisibleCharUtils.js 工具类和 InvisibleCharText.vue 组件的组合,我们构建了一套完整的隐藏字符检测解决方案。该方案不仅解决了实际的安全问题,还提供了良好的用户体验和开发者体验。

核心优势

  • 安全性:有效防护隐藏字符攻击
  • 易用性:一行代码即可集成
  • 扩展性:支持多种自定义配置
  • 性能:高效的检测算法
  • 兼容性:支持各种浏览器环境

在当前网络安全形势日益严峻的背景下,这样的前端安全组件为我们的应用系统提供了重要的安全保障,值得在更多项目中推广应用。