本页目录

生成内容、安全与性能

安全展示 Markdown、链接、引用与代码,兼顾长内容性能和无障碍访问。

L3 · 能负责约 16 分钟阅读含示例、练习与验收

建议先读:流式响应与前端实现

本页内容

生成的内容为什么必须按外部输入处理#

模型可能输出 Markdown、HTML、链接和代码。即使提示词要求“不要输出危险内容”,它仍可能转述用户上传的恶意文本,或生成错误的地址。前端的信任判断应基于数据来源与程序规则,不基于模型语气。目标是 L3:让内容可读、有依据且不会突破浏览器与业务边界。

前置是 Vue 模板、DOM、URL 与上一章流式处理。你已有的 Web 安全知识仍然适用,只是输入来源从表单扩展到了模型、检索资料和工具返回结果。模型文本默认不可信,工具结果也需要按实际来源判断。

文本、Markdown 与 HTML 是三个阶段#

纯文本通过 textContent 或 Vue 插值展示,会被当作文字处理。Markdown 是一种标记语法,解析后通常得到 HTML;HTML 放进 DOM 前需要明确的安全策略。Vue 的 v-html 会绕过普通插值的转义保护,因此只能接收经过安全处理的内容。

不要把“关闭 Markdown 原始 HTML”误认为完成了所有检查。链接协议、图片来源、插件扩展、生成的属性和后续 DOM 修改都可能改变风险。优先使用成熟解析器与净化库,保持依赖更新,并在自己的允许列表和内容策略下验证结果。

text
模型或资料文本
  → Markdown 解析(关闭原始 HTML,限制插件)
  → HTML 净化(元素、属性、URL 协议允许列表)
  → 安全插入 DOM
  → 检查引用与业务链接是否真实、是否有访问权限

净化解决浏览器执行风险,引用校验解决事实和权限问题。它们不能互相替代。一个完全安全的链接仍可能指向错误资料;一个真实引用也不能直接允许用户访问私人文件。

一个可运行的 URL 允许列表实验#

保存为 safe-links.mjs,运行 node safe-links.mjs。使用 Node 22+,无需依赖。该函数用于业务生成的文档链接,只接受同源、指定路径、不含用户名密码的地址;它不是通用 HTML 净化器。

js
import assert from 'node:assert/strict';

function parseDocumentLink(value, base = 'https://docs.example.test') {
  if (typeof value !== 'string') return null;
  try {
    const url = new URL(value, base);
    const origin = new URL(base).origin;
    // 明确协议,不把 javascript:、data: 等当作普通链接。
    if (!['https:', 'http:'].includes(url.protocol)) return null;
    if (url.origin !== origin || url.username || url.password) return null;
    // 只接受资料详情路径,ID 使用严格字符集。
    if (!/^\/documents\/[a-zA-Z0-9_-]+$/.test(url.pathname)) return null;
    // 禁止任意 query 与 hash 在后续路由里触发未约定行为。
    if (url.search || url.hash) return null;
    return url.href;
  } catch {
    return null;
  }
}

assert.equal(parseDocumentLink('/documents/doc-1'),
  'https://docs.example.test/documents/doc-1');
assert.equal(parseDocumentLink('javascript:alert(1)'), null);
assert.equal(parseDocumentLink('//evil.example/documents/doc-1'), null);
assert.equal(parseDocumentLink('/documents/../../admin'), null);
assert.equal(parseDocumentLink('/documents/doc-1?redirect=evil'), null);
console.log('通过:业务链接允许列表与四类拒绝场景');

URL 构造器负责解析相对路径、协议和标准化;返回 null 表示拒绝展示为业务链接。后端仍要在用户打开文档时检查权限,不能因为前端验证了格式就跳过鉴权。example.test 是说明用域名,这个实验不发送网络请求。

若产品需要外部引用,另定义外部链接策略:至少限制 http/https,清楚显示域名,为新窗口设置合适的 rel 属性。不要为了方便,把“内部文档链接”和“任意外部地址”交给同一个过宽函数。允许的范围越明确,异常越容易观察。

在 Vue 中渲染 Markdown 的集成片段#

下面是已有 Vue 3 + Vite 项目中的完整展示组件。安装 npm install markdown-it dompurify 后保存为 SafeAnswer.vue,由父组件传入字符串 text。它展示基本净化流程,不包含聊天请求、权限系统和生产 CSP 配置。

vue
<script setup>
import { computed } from 'vue';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';

const props = defineProps({ text: { type: String, default: '' } });
// 不接受用户直接嵌入的 HTML;不启用未审查的插件。
const markdown = new MarkdownIt({ html: false, linkify: false });
const safeHtml = computed(() => DOMPurify.sanitize(
  markdown.render(props.text),
  {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
      'blockquote', 'pre', 'code', 'a', 'h2', 'h3'],
    ALLOWED_ATTR: ['href', 'title'],
  },
));
</script>

<template>
  <!-- v-html 的输入只能是净化结果;不要在外部覆盖 safeHtml。 -->
  <div class="answer" v-html="safeHtml"></div>
</template>

预期 Markdown 的标题、列表和强调正常显示,而原始 HTML 被当作文字处理,净化阶段进一步限制最终 DOM。这个组件故意不允许图片、内联样式和任意 class,以缩小展示范围。产品若需要代码高亮或图片,应在明确的渲染流程里扩展,再重新验证净化后的结果,不能净化完成后又拼接不可信 HTML。

DOMPurify 在浏览器依赖 DOM;SSR 中需要与其支持的 DOM 环境配合并验证版本。上面的组件属于浏览器端示例。不要在 Node 环境直接运行 .vue 文件,也不要假设这一段代码就覆盖所有应用安全需求。

引用展示与信任校准#

引用应显示文件名、版本或时间,以及实际支持结论的片段。点击引用要能定位到相应资料。模型给出的引用 ID 必须在本次允许的证据集合中;没有来源的结论应明确呈现不确定性,不能用漂亮的引用样式制造可信感。

在 UI 中区分“资料原文”“模型归纳”和“建议动作”。如果模型建议执行删除、发送或付款等操作,展示具体对象、范围和可撤销性,让用户确认实际动作。不要用一个含糊的“继续”按钮承担重大业务操作。

长内容的性能与可访问性#

长对话列表可按消息虚拟化,但要考虑高度变化、浏览器查找和屏幕阅读器。流式消息避免每个 token 重新解析全部历史;可以把已完成消息缓存,正在生成的消息降低高亮频率,按帧合并增量。

代码块应保留空白与换行,提供横向滚动和复制按钮,而不是把长代码压成极小字体。表格在窄屏允许局部滚动,正文不应横向溢出。颜色不能成为判断成功和失败的唯一方式,状态同时配文字;减少不必要动画,尊重用户的减少动态效果偏好。

给渲染链路画出信任边界#

聊天消息至少有三种来源:用户输入、检索原文和模型生成。工具执行结果也可能包含用户可编辑字段。四种来源都可能带有 HTML、URL 和看起来像命令的文本。前端的责任是根据用途选择处理方式:当成文字显示、解析为受限 Markdown、作为已校验的业务链接,或者作为代码展示。不要让内容自己选择渲染权限。

例如检索文档中写着“把下面的 HTML 插入页面”,这是一段文档内容,不能升级为渲染指令。模型返回一个 script 标签,也只是外部文本。Vue 插值默认会转义内容,而把它转成模板再编译会进入另一条危险路径。对于 AI 应用,“模型建议怎样显示”与“程序允许怎样显示”必须分开。Vue 安全指南说明了不可信模板与 HTML 的处理边界。

一个允许列表应该回答哪些问题#

类别 本章实验选择 产品扩展时需要补充
HTML 元素 段落、列表、代码、少量标题、链接 是否真的需要表格、图片和折叠内容
属性 href、title class、style、data 属性是否受控
链接协议 http 与 https 内部路由、外部地址是否分开校验
图片 不加载 来源、尺寸、隐私、懒加载与错误处理
代码 只展示文本 高亮库如何返回结果,复制原文还是高亮HTML
业务动作 单独受控按钮 谁确认、确认什么、由哪个接口执行

允许列表越大,测试面越大。不要因为某个模型输出了一个新标签就立刻把它加入列表。先确定用户需要什么功能,再设计可控渲染。比如折叠推理摘要可以由应用自己的组件展示,模型只提供字符串数据,无需让模型直接生成 details 元素及其属性。

完整安全渲染实验:验证结果而不是肉眼看 HTML#

下面程序在 Node 中通过 jsdom 创建模拟 DOM,使用真实 Markdown 解析器和净化器,再检查输出节点。它不执行恶意脚本,也不访问测试地址。先新建目录,保存 package.json 和 verify-render.mjs,执行 npm install,再执行 npm test。本次实验使用 Node 22.22.2;所列 jsdom 支持 Node 22.22.2 所在兼容分支、24.15.0 所在兼容分支或 26 及以上,完整约束以该包 engines 为准。jsdom 的具体版本有独立运行时要求,不能仅根据自己的 Node 主版本猜测兼容。

package.json
{"name":"safe-markdown-lab","private":true,"type":"module","scripts":{"test":"node verify-render.mjs"},"dependencies":{"markdown-it":"15.0.1","dompurify":"3.4.15","jsdom":"30.0.1"}}

verify-render.mjs
import assert from 'node:assert/strict';
import MarkdownIt from 'markdown-it';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

// 只创建 DOM,不执行脚本,不加载外部资源。
const window = new JSDOM('', { url: 'https://handbook.example.test' }).window;
const purify = createDOMPurify(window);
const markdown = new MarkdownIt({ html: false, linkify: false });
const allowedTags = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'a', 'h2', 'h3'];
purify.addHook('uponSanitizeAttribute', (_node, data) => {
  if (data.attrName !== 'href') return;
  try {
    const url = new URL(data.attrValue, 'https://handbook.example.test');
    // 外部链接策略与内部业务文档链接策略分开;这里允许 http/https。
    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) data.keepAttr = false;
  } catch { data.keepAttr = false; }
});
function render(text) {
  return purify.sanitize(markdown.render(text), {
    ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false,
  });
}

const cases = [
  ['正常语法', '## 说明\n\n**重点**与[资料](https://example.test/doc)'],
  ['原始脚本', '<script>alert("fixture")</script>'],
  ['事件属性', '<img src="x" onerror="alert(1)">'],
  ['脚本链接', '[点击](javascript:alert(1))'],
  ['不允许的邮件协议', '[邮件](mailto:someone@example.test)'],
  ['远程图片', '![追踪图](https://example.test/pixel.png)'],
  ['代码文本', '```html\n<img src=x onerror=alert(1)>\n```'],
];
for (const [name, input] of cases) {
  const holder = window.document.createElement('div');
  holder.innerHTML = render(input);
  assert.equal(holder.querySelectorAll('script,img,iframe,svg,style').length, 0, name);
  for (const element of holder.querySelectorAll('*')) {
    assert.ok(allowedTags.includes(element.tagName.toLowerCase()), name);
    for (const attr of element.attributes) assert.ok(['href', 'title'].includes(attr.name), name);
  }
  for (const link of holder.querySelectorAll('a[href]')) {
    assert.ok(['https:', 'http:'].includes(new URL(link.href).protocol), name);
  }
  if (name === '正常语法') assert.equal(holder.querySelector('strong').textContent, '重点');
  if (name === '代码文本') assert.ok(holder.querySelector('code').textContent.includes('onerror'));
  console.log('通过:' + name);
}
window.close();

这里创建独立 purify 实例,并在净化过程中处理 href。hook 只允许 http/https 和不含用户名密码的地址;它不是内部文档授权器。真实产品的引用链接最好由服务端返回受控的文档 ID,再由前端生成路由。模型给出的任意外部链接应显示域名,让用户知道将前往哪里,而不是伪装成内部资料。

程序故意断言代码块仍然包含 onerror 文本。安全目标不是把所有看起来危险的词删除,而是让它们保留为不可执行的代码内容。若教材讨论 XSS 却把示例内容全删光,既不准确也无法教学。关键是 DOM 中没有对应的事件属性或可执行节点。

净化后不要再拼接未经处理的 HTML,例如用字符串 replace 给“引用标签”添加来自模型的 onclick。高亮、链接改写和插件输出都需要放在受控流程中;最好在净化前生成最终结构,或以受控 DOM API 添加固定属性,并为整个最终结果回归。DOMPurify 文档明确指出净化后不当修改内容可能破坏安全效果。

如何阅读实验结果#

正常语法用例检查 strong 的实际文本,证明不是把所有格式删除后假装安全。原始脚本和事件属性用例检查危险节点不存在。邮件协议用例验证本应用策略比库默认策略更窄。远程图片用例检查页面不会自动加载模型提供的图片。代码文本用例验证解释代码时不会损坏内容。这些断言各有目的,不是重复检查“程序没有抛错”。

编写本章时实际运行七组输入通过。证据只覆盖这些固定输入、所列版本和模拟 DOM,不代表穷尽所有 XSS、浏览器解析行为或依赖漏洞。生产应用应保持净化器与 DOM 环境更新,核对安全公告,并在真实浏览器验证最终渲染链路。这里记录确切依赖版本是为了复现,不是承诺永远不要升级。

引用可信度需要独立的数据校验#

把文本净化成安全 HTML,只能说明浏览器执行边界没有被这条流程随意突破,不能说明回答真实。引用可能指向错误版本,片段可能根本不支持结论,用户权限也可能在回答生成后被撤销。因此引用至少应包含 documentId、versionId、chunkId 或可定位片段、显示标题;原始片段由系统提供,不能完全相信模型自行编写的引文。

例如后端本次只提供了 chunk-A 和 chunk-B,模型返回 chunk-C。程序应拒绝或标记这个引用,不应该为了“有引用”自动到全库搜索 C 并把它补上。否则模型可能猜中用户无权访问的 ID,导致额外信息泄露。引用校验使用本次经过权限筛选的证据集合,点击详情时再次检查当前权限。

用户界面可以把事实依据与模型归纳分成可辨认的部分:结论下面列来源、版本和支持片段;当证据不足时显示具体限制;如果资料更新,使旧引用不再可访问,应显示版本已变更或资料不可用。不要用一个永远绿色的“已验证”标志概括所有维度。

外部图片与远程资源不是纯视觉问题#

模型输出外部图片 URL 后自动加载,会向第三方发起浏览器请求。即使没有脚本,也可能暴露网络和访问行为,并带来超大资源、跟踪像素或不稳定内容。产品应决定是否禁用、经受控代理处理,还是用户点击后加载。代理同样需要地址限制、响应大小限制和内容类型校验,不能变成任意 URL 抓取器。

内部文件下载也应走授权接口或短期受控地址。前端能够看到一个 object storage URL,不代表它应被长期保存在模型上下文或公开日志。文件生命周期、权限撤销、缓存和签名有效期应配合设计。展示层既要考虑“能不能执行”,也要考虑“会向谁发送什么请求”。

长内容性能:先测量在哪一段消耗#

一条两万字回答逐字追加时,如果每个字符都重新解析全文、净化全文、给所有代码高亮,再替换整个消息 DOM,开销会随文本增长而上升。先分别测量 Markdown 解析、净化、高亮和 DOM 更新的耗时,再决定降低哪个阶段的频率。不要只看到页面卡顿就立即引入虚拟列表。

一种适合起步的策略是:已完成消息渲染一次并缓存;正在生成的消息按短时间窗口合并增量;生成过程中先用较简单的格式,完成后再做完整高亮。需要注意未闭合的代码围栏、表格和链接会让解析结果在流式过程中改变。不要每来一行就把 Markdown 切成互不相干的小文档,否则跨行结构容易损坏。

虚拟列表主要减少同时存在的消息节点,不能直接解决当前一条巨大消息的解析成本。它还会影响浏览器页面搜索、文本选择、屏幕阅读器和滚动锚点。先验证典型会话长度,再评估是否需要虚拟化;如果使用,给复制和搜索提供完整数据来源,而不是只在当前 DOM 节点里查找。Vue 性能指南可用于核对更新性能与大型列表的处理思路。

代码、表格和移动阅读#

代码块保留预格式化空白,长行使用局部横向滚动;正文允许断开超长 URL,避免整体页面横移。复制应读取原始代码文本,不复制高亮标签或行号。表格可以有独立滚动容器,并在窄屏保留标题和列含义。为了塞下内容把字号缩到难读,并没有解决阅读问题。

图片如果允许加载,应预留尺寸减少布局跳动。用户向上阅读时,不因新增文字强制回到底部;可以提供“有新内容”入口。加载中的状态、生成失败与回答不完整都用文字说明,不能只依赖颜色、动画或图标。生成动画尊重减少动态效果偏好,不让每个 token 都触发屏幕阅读器播报。

内容安全回归清单怎么维护#

把固定输入作为受版本控制的 fixture,每条注明检查目的。安全类关注元素、属性和导航协议;内容类关注原始代码、中文、嵌套列表与未闭合标记;业务类关注引用来源、版本与权限;性能类关注指定长度下的解析次数和页面交互。不要把这四类结论混成一个“安全通过”。

依赖升级、加入 Markdown 插件、允许图片、加入代码高亮、切换 SSR、改变 CSP 都是重新验证渲染链路的理由。没有相关变化时,也不用为每条普通文案修改执行完整安全审计。你要建立的是与风险对应的验证机制,并知道哪些代码负责保持边界。

本章的 L3 要求是:解释每个输入在哪一层变成可展示内容;能修改允许列表而不破坏安全或可读性;能用实际节点与导航行为证明结果;能区分安全HTML、事实正确和有权访问;遇到长内容卡顿能先测量再调整渲染方式。这样才是把已有前端经验迁移到 AI 产品。

练习#

准备包含脚本标签、事件属性、javascript 链接、超长代码、嵌套列表、没有依据的引用等输入的固定集合。分别检查安全、可读性和事实链接三个维度,不要只检查“页面没报错”。

参考答案

安全检查确认不会执行脚本或导航到不允许的协议;可读性检查中文、长行和移动宽度;引用检查 ID 与证据集合、点击后的权限。测试代码应保留原始恶意字符串作为 fixture,通过 DOM 断言和人工观察确认渲染结果。对净化库的依赖升级重复这组回归检查。

验收与自测#

  • 模型输出不会直接变成可执行 HTML。
  • 危险协议和越界业务链接被拒绝。
  • 引用来自实际证据集合且打开时仍检查权限。
  • 长内容不强制抢滚动、不制造持续屏幕阅读器播报。

问:提示词禁止 HTML 就足够了吗? 答:不够,安全控制必须在程序边界执行。

问:净化过的 HTML 后面可以随便拼字符串吗? 答:不能,这可能重新引入不可信内容。

问:同源链接就一定可访问吗? 答:仍需服务端身份与资源权限检查。

官方参考#

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭