外观
前端安全加固清单:XSS、CSRF、CSP 与依赖供应链,一次渗透测试整改实录
渗透测试报告发下来的那天,团队面对的是一份三十多页的清单:存储型 XSS 两处、中危 CSRF 一处、CSP 缺失、若干依赖高危漏洞。好消息是没有一个漏洞用到了多么高深的技术——全是前端生态里最常见的攻击面。坏消息也在这里:这些问题每一个都「早知道」,但就是没人在上线前花半天时间过一遍。
这篇清单就是那次整改的沉淀,按 XSS、CSRF、CSP、依赖供应链四条防线组织,每条给出可直接落地的代码。建议收藏,每次上线前对照过一遍,比事后救火便宜得多。
XSS:把不可信数据关进笼子
XSS 的本质只有一句话:不可信数据被当成了代码执行。前端场景里三个典型注入点,整改时逐个排查:
注入点一:富文本渲染。React 的 dangerouslySetInnerHTML、Vue 的 v-html 都是逃生舱——框架默认会转义文本,但这两个口子会把 HTML 原样插入。凡是从接口、用户输入、CMS 来的富文本,插入前必须过白名单消毒:
js
import DOMPurify from "dompurify";
// 富文本必须经白名单消毒后才能插入 DOM,禁止直接 v-html 接口原始数据
const clean = DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: ["p", "b", "i", "a", "img", "ul", "li"],
ALLOWED_ATTR: ["href", "src", "alt", "class"],
// 禁止任何内联事件与脚本类标签,onerror、onclick 之类直接剥离
FORBID_ATTR: ["style", "onerror", "onclick"]
});注入点二:URL 与跳转。javascript: 伪协议、window.open(用户可控值) 都是经典入口。跳转前校验协议白名单(只放 http/https 与站内相对路径),a 标签的 href 同样要过滤。
注入点三:DOM 属性与 postMessage。addEventListener("message") 收到的消息必须校验 event.origin,别假设「只有自家页面会发消息」;动态拼 HTML 字符串的写法(el.innerHTML = "<div data-x='" + value + "'>")一律改为 createElement + textContent 或框架模板。
整改的降损配套是把会话 Cookie 设为 httpOnly,让脚本即便注入成功也偷不走凭证——但这只是兜底,不能替代消毒。
CSRF:SameSite 不是万能护身符
CSRF 的原理是浏览器「自动带 Cookie」的特性被滥用:用户在 A 站登录着,被诱导访问恶意页面,恶意页面向 A 站发起请求,Cookie 自动附上,请求被当成合法用户。
现代浏览器的默认 SameSite=Lax 已经挡住了大部分跨站请求,但有两个缺口要补:一是 Chrome 系默认 Lax 之外,显式设置仍然必要(有的环境会被改成 None),二是 Lax 只拦跨站 POST,GET 类副作用接口照样能被命中。整改配置:
js
// Express 示例:会话 Cookie 三件套
res.cookie("session", token, {
httpOnly: true, // 前端脚本读不到,防 XSS 偷凭证
sameSite: "lax", // 拦截绝大多数跨站携带
secure: true, // 仅 HTTPS 传输
maxAge: 1000 * 60 * 60 * 2
});SameSite 之外的标配是 CSRF Token:服务端下发随机 token,前端放进请求头(Axios 拦截器统一注入),服务端校验。用自定义请求头本身也有防护作用——跨站的 <form> 发不出自定义头,预检请求会被 CORS 拦下。两种手段叠加后,CSRF 基本就堵死了。
CSP:给页面里的每个脚本上户口
内容安全策略(CSP)是 XSS 的最后一张网:即便有脚本注入成功,CSP 也限制它能从哪里加载、能不能执行。生产环境必须走响应头而不是 meta 标签(后者能力受限),以 Nginx 或网关下发为例:
nginx
# CSP 响应头:白名单放行自家域与必要的 CDN
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; report-uri /csp-report" always;这份配置里有几个关键决策:object-src 'none' 与 base-uri 'self' 是低成本高收益的必配项;frame-ancestors 'none' 阻止被嵌套钓鱼;script-src 坚决不放行 unsafe-inline——内联脚本一旦放行,CSP 对 XSS 的防护价值就折损大半,应该用 nonce 方案替代(服务端为每次响应生成随机 nonce,脚本标签携带该值才可执行)。
落地的正确姿势是灰度:先发一份 Content-Security-Policy-Report-Only 头,只上报违规不实际拦截,收集一两周真实违规日志,把合法来源补进白名单后再切正式头。一上来就全量拦截,第三方脚本、埋点、内联样式会瞬间把页面打瘫。
依赖供应链:最容易被忽视的正面攻击面
近几年主流开源包被投毒的真实事件不止一起,套路高度一致:维护者账号被盗或包被恶意接管,投毒版本发布后,锁定了精确版本的项目不受影响,写了 ^ 范围并且重装依赖的项目中招。整改清单按投入产出排序:
第一,锁文件必须提交,CI 一律 --frozen-lockfile 安装,杜绝「本地能装、线上装错」:
yaml
# GitHub Actions 片段:依赖安装与审计
- run: pnpm install --frozen-lockfile # 锁文件不一致直接失败
- run: pnpm audit --audit-level high # 高危以上漏洞使流水线失败第二,依赖最小化。每个传递依赖都是潜在攻击面:能三行代码自己写的就不引小包;临时调研用的依赖用完就删;定期跑 pnpm dlx depcheck 清理死依赖。
第三,registry 固定与来源校验。.npmrc 里把 registry 锁定为私有源或官方源,防止依赖混淆攻击(同名内部包被公共仓库上的恶意包抢注后,构建机去公共源拉到了投毒版);对关键第三方 CDN 脚本加 SRI 完整性校验,文件被篡改时浏览器拒绝执行。
避坑总结
- 前端消毒永远不能替代后端防护。存储型 XSS 的数据落在库里,出口可能是服务端渲染、接口直出、小程序等任何一端;前端过滤只保护自家页面,入库前在服务端编码、出口处按上下文转义才是完整闭环。
- CSP 必须灰度切换。Report-Only 观察期至少一周,重点看第三方脚本的动态加载行为;直接全量拦截的翻车率极高,且排查时页面已经瘫了。
- SameSite=None 必须配套 Secure。跨站场景(如嵌入式 SSO)需要 None 时,不设 Secure 的 Cookie 会被现代浏览器直接拒绝,症状是「登录后马上掉线」,极难联想到 Cookie 属性。
- postMessage 的 origin 校验写在最前面。很多团队只在接收消息处做功能,校验被忘掉;收消息第一行就判断
event.origin,非白名单直接 return,并把这条写进代码评审 checklist。 - 依赖升级要常态化而不是运动式。平时依赖 Renovate/Dependabot 的小步升级,比攒半年后一次性大升级的风险小得多;高危漏洞出现时也只有小步升级的团队能当天响应。
写在最后
安全整改最大的收获不是修掉那几个漏洞,而是团队从此有了「攻击面」的意识:每一个渲染 HTML 的口子、每一枚 Cookie 属性、每一个引入的依赖,都是给外部世界开的一扇窗。清单的价值在于把「窗户要关」变成例行检查——上线前的十分钟自查,永远是最便宜的安全投入。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。