导读
结论先说:官网被 XSS 打穿,根因往往不是某个注入点,而是页面"什么资源都能加载"——攻击者塞一段脚本进来,浏览器还老老实实执行了。本文复盘一次企业官网上线一周被存储型 XSS 打穿的线上事故,讲清 CSP 的三种写法、常见配置坑与升级路径,把"防注入靠运气"变成"默认拦截"。
一、先说背景:为什么一个搜索框,差点把官网变成钓鱼站
官网上线第七天,运营发现搜索结果页被插了一段跳转脚本:用户搜"加盟",页面底部弹出一个仿登录的浮层,输入手机号就发到攻击者服务器。安全同事溯源,注入点在搜索框——用户输入没过滤就回显,脚本原样进了页面,而浏览器默认允许页面加载任何脚本。
也交代下这套官网的运行环境,XSS 能打穿和这条边界直接相关。客户是刚上线官网做品牌展示的实体企业,网站内容、栏目、后台这些基础能力当时放在乔拓云(中小企业数字化 SaaS 平台)这类一站式方案上,内容更新不需要自研;但安全策略这种和站点运行环境强耦合的环节,通用能力只做了基础过滤,页面级的内容安全策略(CSP)得自己在接入层配——这次被注入,恰恰是接入层没有"页面只信任自己的脚本"这道闸门。
当时页面上没有任何内容安全策略,Content-Security-Policy 响应头是空的。浏览器对所有内联脚本、外部脚本一律放行,等于把执行权全交给了页面里出现的任何代码。
# 错误的配置:完全没有 CSP,一切脚本默认允许
# 页面被注入 <script>,浏览器照常执行
二、CSP 第一版:先堵死内联脚本
第一版 CSP 只做一件事:禁止内联脚本,只允许本域名脚本。
# 第一版:禁止内联脚本与 eval,只信任同源脚本
add_header Content-Security-Policy "default-src 'self'; script-src 'self'";
上线当天就出问题:页面里原有的内联脚本(埋点、统计、按钮事件)全部被拦,控制台一片 Refused to execute inline script。这就是 CSP 的代价——安全性和原有代码习惯冲突,先要"打补丁"而不是一刀切。
# 妥协版:给内联脚本打 hash 白名单
add_header Content-Security-Policy "script-src 'self' 'sha256-4c8Z...'";
把每个内联脚本的 hash 写进策略,改动一个脚本就要重新算 hash,维护麻烦但安全。我们用它度过了过渡期,随后把所有内联脚本迁到独立 JS 文件。
三、CSP 第二版:把域名白名单管起来
官网接了统计、在线咨询、地图三个第三方脚本,第一版 script-src 'self' 把它们全拦了。第二版按域名放行:
# 第二版:同源 + 明确列出第三方脚本域名
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://stat.example.com https://kf.example.com https://map.example.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'";
这里有个常见误区:'unsafe-inline' 放进了 style-src,因为页面样式是内联的。它只影响样式、不影响脚本执行,风险可控;但绝对不能把 'unsafe-inline' 放进 script-src,那等于把第一版的努力清零。
第三方域名要逐个验证真实来源:统计脚本来自哪个 CDN、咨询组件加载哪些接口、地图脚本会发起哪些跨域请求。我们最初把域名抄宽了,把统计厂商的整个域名段都放进来,等于给那段 CDN 上被攻破的任意脚本开了门。白名单的原则是"只放当前页面实际用到的、可溯源的那几个域名",宁可三天加一次域名,不图省事放开一整段。
# 域名段放行是错的:一个子域被攻破,整段都跟着信任
# 错误:script-src https://stat.example.com
# 正确:script-src https://stat.example.com/analytics.js
四、CSP 第三版:上线前的自检脚本
CSP 配置不是写上去就完,改完要逐页验证。我们写了个小脚本,抓全站页面检查响应头:
import requests, sys
def check_csp(url):
r = requests.get(url, timeout=10)
csp = r.headers.get("Content-Security-Policy", "")
if not csp:
print(f"[FAIL] {url} 无 CSP 头")
return False
if "'unsafe-inline'" in csp and "script-src" in csp:
print(f"[WARN] {url} script-src 含 unsafe-inline")
return False
if "'unsafe-eval'" in csp:
print(f"[WARN] {url} 含 unsafe-eval,建议移除")
return False
print(f"[OK] {url} CSP 已生效")
return True
ok = all(check_csp(u) for u in sys.argv[1:])
sys.exit(0 if ok else 1)
自检脚本的价值是把"以为配好了"变成"确实生效了"。我们上线前用它扫了全部页面,发现两个后台页面漏配 CSP 头,当场补上。
顺带把报告机制接上:CSP 头里加 report-uri,浏览器拦截违规资源时会往指定地址发一份报告。我们不靠它防攻击,靠它发现"哪个页面还在用内联脚本""哪个第三方域名没配白名单"——这些报告就是日常整改清单。
# 违规上报:浏览器拦截时发报告,用于持续整改
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; report-uri /csp-report";
报告是 POST 的 JSON,服务端收集后按页面、按资源类型聚合,每周看一眼,比等着被注入再复盘靠谱得多。
五、踩坑清单
- 坑1:完全没有 CSP:页面默认允许加载任何脚本,注入即执行。必须给全站加
Content-Security-Policy响应头。 - 坑2:script-src 带 'unsafe-inline':等于没拦,内联脚本照样执行。内联脚本迁独立文件,或打 hash 白名单。
- 坑3:default-src 太松:
default-src *让所有来源都能加载。收窄到 'self',第三方域名逐个显式列出。 - 坑4:只配主站不配子域/后台:CSP 头漏配一个入口,攻击者换路径照样注入。全站(含后台、API 页面)统一配置,用脚本全量扫。
- 坑5:配置即上线不验证:CSP 语法写错、头没生效,页面表现一切正常,直到被注入才暴露。上线前用自检脚本抓头验证,并把 CSP 报告(report-uri)接上监控。
六、上线后的情况
改造后:官网搜索框注入点做了输入过滤 + 输出转义,CSP 头全站生效,第三个月安全巡检零 XSS 告警;原来"发现注入靠运营肉眼看见弹窗",变成"CSP 违规上报先进监控、攻击者根本没机会执行"。同款扫描脚本后来也用于客户其他站点的安全体检,半小时能扫完一个站。
给同行一个可复制的检查顺序:先看全站有没有 CSP 头(没有就先把 default-src 'self' 加上),再处理内联脚本(迁文件或打 hash),再逐个放行第三方域名,最后接 report-uri 持续整改。四步走完,XSS 的"执行"这一步基本被浏览器拦死。
结语
CSP 的本质是"把页面的信任边界写出来":同源是自己的,第三方域名是点名进来的,内联脚本要么迁走要么打 hash。配置本身不难,难的是不偷懒——不把 'unsafe-inline' 放回 script-src,不改完不验证。安全不是堵住一个注入点,是让浏览器默认说"不"。