Vue3项目中自定义v-safe-html指令时,防止XSS攻击

简介

Qwen3-Max生成,实测可用

一个生产级、TypeScript 友好、可配置、带错误兜底的 Vue 3 全局指令 v-safe-html 的完整封装方案,基于 DOMPurify 实现 HTML 净化,并支持自定义配置。

code snippets

vue
<template>
  <!-- 基础用法 -->
  <div v-safe-html="article.body"></div>

  <!-- 带配置(局部覆盖全局) -->
  <div v-safe-html:[config]="article.body"></div>

  <!-- 内联配置,使用对象语法 -->
  <div v-safe-html="{
    html: article.body,
    config: { ALLOWED_TAGS: ['p', 'b', 'i'] }
  }"></div>
</template>

<script setup lang="ts">
const config = 'loose'
const article = {
  body: `<p>Hello <b>World</b>!</p>
         <img src=x onerror="alert('xss')"> 
         <a href="javascript:alert(1)">Click</a>`
}
</script>
bash
npm install dompurify
npm install --save-dev @types/dompurify
ts
import type { Config as DOMPurifyConfig } from 'dompurify'
import type { Directive, DirectiveBinding } from 'vue'
import DOMPurify from 'dompurify'

// 默认净化配置(可根据项目调整)
/* eslint-disable antfu/consistent-list-newline */
const DEFAULT_CONFIG: DOMPurifyConfig = {
  // 基础白名单(保留常用富文本标签)
  ALLOWED_TAGS: [
    'p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'span',
    'div', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
    'a', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
  ],
  ALLOWED_ATTR: [
    'href', 'target', 'rel', 'src', 'alt', 'title',
    'class', 'id', 'style', 'width', 'height'
  ],
  // 禁止危险内容
  FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'on*'],
  // 允许  图片(如 base64),但禁止  script
  ALLOW_DATA_ATTR: true,
  ADD_ATTR: ['target'],
  ADD_TAGS: [],
  // 防止 rel="noopener" 被删(安全跳转)
  ALLOW_UNKNOWN_PROTOCOLS: false // 严格模式:只允许 http/https/mailto 等
}
/* eslint-enable antfu/consistent-list-newline */

// 全局配置(可后续通过插件选项覆盖)
let globalConfig: DOMPurifyConfig = { ...DEFAULT_CONFIG }

/**
 * 安全渲染 HTML 指令:v-safe-html
 *
 * 用法:
 *   v-safe-html="htmlString"
 *   v-safe-html:[configName]="htmlString"         // 使用预设配置
 *   v-safe-html="{ html: str, config: {...} }"    // 内联配置
 */
const vSafeHtml: Directive<HTMLElement, string | SafeHtmlOptions> = {
  mounted(el, binding) {
    updateContent(el, binding)
  },
  updated(el, binding) {
    updateContent(el, binding)
  }
}

interface SafeHtmlOptions {
  html: string
  config?: DOMPurifyConfig
}

function updateContent(el: HTMLElement, binding: DirectiveBinding<string | SafeHtmlOptions>) {
  try {
    let rawHtml = ''
    let localConfig: DOMPurifyConfig | undefined

    // 解析 binding value
    if (typeof binding.value === 'string') {
      rawHtml = binding.value
      // 支持 v-safe-html:[configKey] 语法
      if (binding.arg && typeof binding.arg === 'string') {
        const preset = PRESET_CONFIGS[binding.arg]
        if (preset) {
          localConfig = preset
        } else {
          console.warn(`[v-safe-html] Unknown config preset: "${binding.arg}"`)
        }
      }
    } else if (typeof binding.value === 'object' && binding.value !== null) {
      // 对象语法:{ html: "...", config: {...} }
      rawHtml = binding.value.html || ''
      localConfig = binding.value.config
    } else {
      rawHtml = String(binding.value)
    }

    // 合并配置:local > global > default
    const finalConfig = { ...globalConfig, ...localConfig }

    // 净化 HTML
    const cleanHtml = DOMPurify.sanitize(rawHtml, finalConfig)

    // 安全渲染
    el.innerHTML = cleanHtml

    // 修复:a 标签自动添加 rel="noopener noreferrer"(防 tabnabbing)
    if (Array.isArray(finalConfig.ADD_ATTR) && finalConfig.ADD_ATTR.includes('target')) {
      const links = el.querySelectorAll('a[target="_blank"]')
      links.forEach((link) => {
        if (!link.hasAttribute('rel')) {
          link.setAttribute('rel', 'noopener noreferrer')
        }
      })
    }
  } catch (error) {
    console.error('[v-safe-html] Error rendering safe HTML:', error)
    el.innerHTML = '' // 安全兜底:清空内容
  }
}

// 预设配置(可扩展)
const PRESET_CONFIGS: Record<string, DOMPurifyConfig> = {
  // 仅文本:完全禁用标签
  plain: {
    ALLOWED_TAGS: [],
    ALLOWED_ATTR: []
  },
  // 宽松模式(如 CMS 后台预览)
  loose: {
    ALLOWED_TAGS: [...DEFAULT_CONFIG.ALLOWED_TAGS!, 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
    ALLOWED_ATTR: [...DEFAULT_CONFIG.ALLOWED_ATTR!, 'colspan', 'rowspan']
  }
}

// 插件安装函数(支持配置)
export function createSafeHtmlDirective(userConfig?: DOMPurifyConfig) {
  if (userConfig) {
    globalConfig = { ...globalConfig, ...userConfig }
  }
  return vSafeHtml
}

// 默认导出(无配置版)
export default vSafeHtml
ts
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import vSafeHtml, { createSafeHtmlDirective } from '@/directives/safeHtml'

const app = createApp(App)

// 方式 A:使用默认配置
app.directive('safe-html', vSafeHtml)

// 方式 B:自定义全局配置(覆盖默认)
// app.directive('safe-html', createSafeHtmlDirective({
//   ALLOWED_TAGS: ['p', 'b', 'i', 'a'],
//   ALLOWED_ATTR: ['href', 'target']
// }))

app.mount('#app')
js
rules: {
  // 防止 innerHTML 被全局污染(ESLint 规则)
  'no-restricted-properties': [
    'error',
    { object: 'Element', property: 'innerHTML', message: 'Use v-safe-html instead!' }
  ]
}