入口的注入堵住了,出口还有一类攻击:恶意脚本随内容入库,等某个页面把它渲染出来,就在别人的浏览器里执行——会话凭据、页面内容、用户操作全部暴露。这就是跨站脚本(XSS)。本节承接 5.1 的转义输出:那里的「默认转义线」正是本节防线的日常形态,本节把它扩展成完整的出口防御体系,并处理最难的富文本场景。学完你应当能区分三类 XSS、配置出口转义、并为富文本搭一条白名单净化链。
按脚本的来路,XSS 分三种。存储型:恶意片段存进库(评论、昵称、简介),每个浏览者都中招,危害最大;反射型:恶意片段藏在请求参数里,服务器原样回显(搜索词回显是经典现场),需要诱导用户点特定链接;DOM 型:问题出在前端脚本直接把不可信数据写进页面结构,与后端渲染无关但同属出口纪律。三种来路不同,共同解只有一个——出口转义:内容渲染进 HTML 的那一刻,凡是文本就必须以文本形态出现。
框架的默认转义线(5.1 已详述)覆盖了模板出口,这里补齐接口出口——JSON 接口不经过模板,转义责任落在消费端,但服务端仍有一条底线:响应头声明内容类型,防止浏览器把接口响应当 HTML 猜测渲染:
// JSON 响应显式声明类型,杜绝内容嗅探 return json($data)->contentType('application/json'); // 下载类响应强制附件语义,防止内联渲染 return download($file, 'report.csv');
配合 5.1 的纪律(raw 逐个审批),纯文本场景的出口防线就闭合了。剩下的难点只有一个:业务上必须输出 HTML 的内容——富文本。
富文本(图书介绍、公告正文)是唯一合法的 HTML 出口,也是存储型 XSS 的主航道。防线是「服务端白名单净化」:解析提交的 HTML,只保留白名单里的标签与属性,其余一律剥除:
// 净化规则:可用标签与属性的白名单(示例) $allowTags = '<p><br><strong><em><u><ul><ol><li><a><img><blockquote>'; $allowAttrs = ['href' => ['a'], 'src' => ['img'], 'alt' => ['img']]; function purifyRichText(string $html, string $allowTags): string { // 第一步:剥除 script 与事件属性(onerror、onclick 等) $html = preg_replace('#<script[^>]*>.*?</script>#is', '', $html); $html = preg_replace('/\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html); // 第二步:按白名单过滤标签,不在名单内转成纯文本 $html = strip_tags($html, $allowTags); // 第三步:校验链接协议,只留 http 与 https,堵 javascript 伪协议 return preg_replace('/href\s*=\s*([\'"])javascript:/i', 'href=$1#', $html); }
三条铁律:净化必须发生在服务端——前端编辑器的过滤只是体验优化,绕过前端直接 POST 是攻击者的基本操作;净化发生在入库前且只此一次,出口直接 raw 输出净化结果,避免「每处出口各洗一遍」的口径漂移;白名单从最小集起步——先只留段落加粗列表,出现真实排版需求再逐个放行,反向(默认全放行再逐个禁)一定会漏。javascript: 伪协议与内联事件属性是最容易漏的两类,样例里专门列了出来。
新手常见方案是「入库时把所有输入都转义一遍」,这条路有两个坑。其一,转义是出口语义不是入口语义:<b> 是「展示小于号 b 大于号这段文本」,不是数据本身——入库时转义,数据就被永久污染,搜索、导出、非 HTML 消费端(App、小程序)全部拿到脏数据。其二,一处过滤防不住多处出口:同一份数据会进 HTML 页面、进 JSON 响应、进 URL 参数,出口的转义规则各不相同(HTML 实体、URL 编码、JS 字符串转义),入口先洗一遍意味着没有任何出口拿到原始数据,每处出口的再处理都在脏数据上叠加。正确分工回到 6.1 的结论:入口只做验证与清洗(去空格、按规则截断、剥控制字符),出口按消费端语义转义。把这句话贴在团队文档里,输入输出两端的职责就再也不打架。
背景:图书详情的介绍字段需要支持排版,当前直接 raw 输出。操作:实现白名单净化函数并接入保存流程;构造三组攻击载荷入库——脚本标签、图片标签的 onerror 事件、链接的 javascript 伪协议——逐组验证渲染结果;再给响应头补齐内容类型声明。结果示例:三组载荷全部失效,分别呈现为纯文本、剥掉事件属性的图片标签、降级为空锚点的链接。解读:注意净化后内容的「可用性」——白名单太紧会让正常排版一起消失,放宽前先确认新增标签没有脚本能力。变式:把净化规则抽成配置文件并加上单元测试(第 8 章),每个已知载荷一条断言,让净化链在重构中有回归保护。
⚠️ 常见坑:正则净化 HTML 是出了名的高危手艺——嵌套、属性变体、编码花招层出不穷,上面样例的教学版规则不可直接上生产。生产环境选经过社区锤炼的净化库,并把已知攻击载荷做成回归用例,宁可依赖被上千个项目打过补丁的实现,不要自信于自己刚写的正则。
出口转义之外,浏览器侧还有一道可以加码的防线:内容安全策略。思路是把「页面里允许执行哪些来源的脚本」写成响应头交给浏览器强制执行——只允许本域与指定静态资源域的脚本,任何内联脚本与来路不明的脚本一律拒绝执行。它的价值在兜底:哪怕某个出口漏了转义,恶意脚本进了页面也跑不起来。代价是接入要梳理全站脚本来源,历史项目里散落的内联脚本要先收敛。新项目建议从上线第一天就开报告模式观察,逐步切强制模式——这道闸与转义互为备份,构成出口防线的纵深。
出口与入口都布防完毕,最后一节管「谁能走到哪」:上传通道的闸门与权限中间件见 7.3。