# 生成内容、安全与性能

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

模型可能输出 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 安全指南](https://cn.vuejs.org/guide/best-practices/security)说明了不可信模板与 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 主版本猜测兼容。


```json 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"}}

```



```js 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 文档](https://github.com/cure53/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 性能指南](https://cn.vuejs.org/guide/best-practices/performance)可用于核对更新性能与大型列表的处理思路。

### 代码、表格和移动阅读

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

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

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

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

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

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


## 练习

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

<details>
<summary>参考答案</summary>

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

</details>

## 验收与自测

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

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

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

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

## 官方参考

- [Vue：安全](https://cn.vuejs.org/guide/best-practices/security)
- [DOMPurify 官方项目](https://github.com/cure53/DOMPurify)
- [markdown-it 官方项目](https://github.com/markdown-it/markdown-it)
- [OWASP：XSS 防护](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
