前端的安全默认值:CSP、上传与同源

个人网站最容易忽略的安全细节:富文本渲染、文件上传和同源策略。三道防线的“默认值”设计思路。
做了十几年 Web 开发,我越来越觉得"安全"这件事最反直觉的地方在于:它不是你加了什么,而是你默认缺省了什么。大多数项目的安全漏洞,并不是因为工程师不知道某个防护手段,而是因为系统在出厂状态下是"默认放行"的,一个新接口、一个新上传入口、一段新脚本,在没有任何声明的情况下就能跑起来。等你想起来要收口时,已经被历史流量、兼容包袱和"别动别动会炸"的恐惧绑住了手脚。
这也是为什么我最近给自己的个人博客做安全加固时,第一反应不是去堆各种拦截规则,而是反过来问:能不能让系统的默认值就是"拒绝",再把真正需要的能力一条条白名单放出来?这种"安全默认值"(secure-by-default)的思维方式,比事后打补丁要省心得多。本文就沿着这个思路,聊三个和个人前端项目最相关的主题:内容安全策略(CSP)、文件上传,以及同源策略。它们看着分散,底层其实是同一个问题,你愿意信任谁、信任到什么程度。
下面这张图是我那次加固的拓扑思路:把默认拒绝的墙建在最外侧,再逐层开口子。
从"默认拒绝"说起
"默认拒绝"四个字听起来很硬,落地时却非常务实。它的对立面是"默认允许",也就是你现在绝大多数框架的出厂状态:浏览器允许加载任意来源的图片,允许执行任意内联脚本,允许表单把文件 POST 到任意路径,允许响应被 iframe 嵌套。这些能力单独看都合理,组合起来却构成了攻击面。
黑名单思维的失败几乎是注定的。早年我写过允许列表的反例:只拦 .exe、.php 这种"危险扩展名",结果攻击者用 .jpg.phtml 或者大小写变体 .PhP 就绕过去了;只拦 <script> 关键词,对方用 <img onerror> 就进了门。黑名单永远在追着攻击者的想象力跑,而你不可能比全世界所有黑客更聪明。白名单反过来,我只认我列出来的那几种,其余一律不认,攻防双方的负担就此对调。
这种思维在前端安全里几乎处处适用:CSP 是资源加载的白名单,上传校验是文件类型的白名单,同源策略是跨域访问的白名单。统一了心智模型之后,后面的技术细节反而好理解了。
CSP 不是开关,是策略表达
很多文章把 CSP 讲得像"加一行 <meta> 就安全了"。这不对。CSP 是一份浏览器能读懂的策略声明,它告诉你:在我的页面里,脚本、样式、图片、连接分别可以从哪里来。它并不能凭空挡住所有 XSS,但能把"XSS 一旦发生了能造成多大破坏"这件事牢牢掐住。
default-src 的真实含义
default-src 是一个兜底指令。当某个具体资源类型(比如 script-src、img-src)没有单独声明时,浏览器就用 default-src 的值去判断。常见误解是"我写了 default-src 'self' 就等于万事大吉",其实它只是兜底,具体指令一旦出现就会覆盖对应类型。
一个最小可用的起步策略:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'这里 default-src 'self' 的意思是:默认情况下,所有资源只能来自同源。随后 img-src 'self' data: 把图片放开到允许 data: 内联(很多图标方案需要),style-src 允许内联样式。注意我故意没提 script-src,一旦你不在策略里显式声明它而只靠 default-src,脚本也就只能同源加载,这其实是最安全的状态。
script-src 与 unsafe-inline 的取舍
真正的麻烦在脚本。'unsafe-inline' 这个关键字允许页面里直接写 <script>...</script> 或者 onclick= 这类内联事件。可一旦放行 'unsafe-inline',CSP 对 XSS 的遏制力就断崖式下跌,攻击者只要能把一段内联脚本注入 DOM,策略就形同虚设。
我博客早期为了图省事,在一些动态渲染的地方用了内联事件和模板里直接吐出的脚本。加固时我做了两件痛苦但必要的事:
第一,把所有内联脚本迁成外部文件,并通过 script-src 'self'。Vue3 构建产物本来就是分离的 chunk,这块迁移成本不高。
第二,对于确实无法避免的少量内联场景(比如一些第三方统计),用 nonce 或者 hash 替代 'unsafe-inline':
Content-Security-Policy: script-src 'self' 'nonce-abc123XYZ'然后在 HTML 里给那个脚本标签带上同样的 nonce:
<script nonce="abc123XYZ">console.log('这条脚本因为有 nonce 才被放行')</script>nonce 是每次响应都重新生成的随机值,攻击者无法提前猜到,因此注入的脚本拿不到合法 nonce,自然被拦截。hash 方案则是对脚本内容做摘要,内容不变就放行。两者都不依赖 'unsafe-inline',安全性好得多。
取舍的底线是:永远不要为了"先上线再说"而长期保留 'unsafe-inline'。它是最方便的,也是最危险的默认值。如果暂时要用,务必配合 report-only 观察一段时间再决定。report-only 灰度
CSP 有个极其友好的机制:Content-Security-Policy-Report-Only。带上这个头时,浏览器不会真正拦截违规资源,只会把违规事件上报到你指定的地址。这让你能在不破坏线上业务的前提下,先摸清"我这套策略到底会误伤多少东西"。
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report把 report-uri(或更新的 report-to)指向一个能接收 JSON 上报的端点,跑上一两周,把真实命中的资源整理出来:哪些是误报(你得改代码适配白名单),哪些是真问题(本来就不该加载的第三方脚本)。等误报清零,再把头从 Report-Only 换成真正的 Content-Security-Policy。
我正是靠这一步发现博客里藏着三个早已没人维护的第三方挂件,它们的加载请求全部违规。与其把它们加进白名单,我直接把挂件下线了,既安全又少加载几十 KB。
文件上传:最容易被轻视的入口
个人项目里文件上传往往是最随意的模块,"反正就我自己传头像和插图"。可一旦你把博客开放给访客投稿、开放头像上传,或者只是把管理后台密码设得不够强,上传入口就是一颗定时炸弹。它危险,是因为它同时涉及存储、执行上下文和浏览器信任链。
扩展名伪装与 MIME 嗅探
最经典的绕过是扩展名伪装:攻击者把 shell.php 改名为 avatar.jpg,你的后端如果只检查扩展名,就可能把它当图片存下来;若恰好落在可被 Web 服务器当脚本执行的目录,那就直接 RCE 了。
更隐蔽的是 MIME 嗅探。早期浏览器会根据文件内容(而不是声明的 Content-Type)去猜测类型,于是有些攻击把 HTML/脚本塞进一个看起来像图片的文件里,骗过存储后,再通过某种方式让浏览器"嗅探"出它是可执行内容。现代浏览器默认开启了 X-Content-Type-Options: nosniff,能强制浏览器只用声明的类型,不嗅探,这一步一定要开。
我的做法是双层校验:前端只做体验性校验,真正的信任全部放在后端。
from pathlib import Path
import imghdr
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
ALLOWED_MAGIC = {"jpeg", "png", "gif", "webp"}
def validate_upload(filename: str, data: bytes) -> str:
ext = Path(filename).suffix.lower()
if ext not in ALLOWED_EXT:
raise ValueError("扩展名不在白名单")
# imghdr 根据文件头判断真实类型,而非看扩展名
real_type = imghdr.what(None, data)
if real_type not in ALLOWED_MAGIC:
raise ValueError("文件内容不是合法的图片")
return extimghdr.what 读的是文件魔数(magic number),攻击者改扩展名改不了文件头,因此骗不过。白名单扩展名加白名单内容类型,两层都是"列举我信的",而非"列举我不信的"。
注意imghdr在 Python 3.13 已被标记为弃用,生产环境建议换用更活跃的库(如python-magic)或自己校验文件头字节。这里用它只是为了示意魔数校验的思路。
SVG 内嵌脚本
SVG 是很多人会忽略的坑。它本质是 XML,但允许内嵌 <script>。如果你把用户上传的 SVG 当作普通图片存到 /file 下,再以 image/svg+xml 响应出去,那个 <script> 会在访客浏览器里执行,等于给对方一个在你域名下跑任意 JS 的机会,也就是存储型 XSS。
处理 SVG 我有三条原则:要么干脆把 .svg 排除在图片白名单之外;要么在存储前用严格解析器剔除其中的 <script>、<foreignObject> 等危险节点;最省事也最稳妥的是下一条,用响应头把上传目录 sandbox 掉。
用响应头 sandbox 隔离 /file
我给博客的上传目录单独配了一组响应头,核心是把整个 /file 目录放进一个被沙箱化的浏览上下文:
location /file/ {
# 禁止浏览器把这里的内容当脚本执行上下文
Content-Security-Policy: sandbox;
# 禁止被其他页面 iframe 嵌套
X-Frame-Options: DENY;
# 禁止 MIME 嗅探
X-Content-Type-Options: nosniff;
# 即使是图片,也要求下载而非内联渲染(对 SVG 尤其关键)
Content-Disposition: attachment;
add_header Cross-Origin-Resource-Policy same-site;
}Content-Security-Policy: sandbox(不带任何白名单参数)是最严格的沙箱:在这个响应里,脚本不能执行、表单不能提交、弹窗不能开、同源策略被强制隔离。即便有攻击者真的把一个带脚本的 SVG 塞了进来,浏览器也会因为它处于沙箱上下文而拒绝执行脚本。这是纵深防御里很划算的一层,你不必完美地拦住每一个恶意文件,只要保证它就算进来了也"跑不起来"。
同源策略是 XSS 的最后一道墙
聊到这儿必须提同源策略。很多人误以为同源策略是"防 XSS 的",其实它防的是"跨站读取":一个 evil.com 的页面没法用 JS 读取 your-blog.com 下带着你登录态的响应内容。XSS 之所以可怕,恰恰是因为攻击者把脚本"种"进了 your-blog.com 自己的同源里,于是同源策略反而成了帮凶,那段脚本天然拥有你的登录凭证访问权限。
所以 CSP、上传隔离、同源策略是连环的:同源策略划定了信任边界,CSP 限制了边界内脚本能干什么,上传隔离又保证边界内不会出现你没预料到的可执行内容。缺了任何一环,另外两环的防护都会被打折扣。
我常跟朋友说:同源策略不是你的盾,它是你的"领地"。XSS 是敌人混进了领地,而 CSP 决定混进来的人能拿多少钥匙。
把加固落到我的博客项目
我的博客是 FastAPI 后端加 Vue3 前端。后端统一在 middleware 里注入安全响应头,前端构建产物走同源,上传走单独的 sandbox 目录。下面是我实际用的中间代码片段(脱敏后):
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
CSP = (
"default-src 'self'; "
"img-src 'self' data:; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"report-uri /csp-report"
)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
resp: Response = await call_next(request)
resp.headers["Content-Security-Policy"] = CSP
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
resp.headers["Cross-Origin-Resource-Policy"] = "same-site"
if request.url.path.startswith("/file/"):
# 上传目录额外沙箱化
resp.headers["Content-Security-Policy"] = "sandbox"
resp.headers["Content-Disposition"] = "attachment"
return resp几个细节值得说:object-src 'none' 禁掉了 <object>/<embed> 这类老牌 XSS 载体;frame-ancestors 'none' 等价于现代版的 X-Frame-Options,防 clickjacking;base-uri 'self' 防止攻击者篡改 <base> 标签把相对路径资源引到恶意站。至于 /file/ 下的请求,我直接用最严的 sandbox 覆盖全局 CSP,确保上传内容永远处于隔离上下文。
前端 Vue3 这边,我顺手把 v-html 的使用面收敛到零,需要渲染富文本的地方统一走经过消毒的库(如 sanitize-html 在服务端预处理),绝不让用户内容直接进 v-html。
配套的信任链:SRI、Cookie 与跨源隔离
CSP 管的是"资源从哪来",但还有几条线属于同一张信任网,值得顺手收紧。它们不常被放进"安全默认值"的讨论,却直接影响 XSS 一旦发生后能造成的破坏半径。
子资源完整性(SRI)解决的是"我引用了外部脚本,但怎么保证它没被篡改"。当你确实要从 CDN 引入一个库(比如某个你无法自托管的第三方 JS),给它加上 integrity 属性:
<script
src="https://cdn.example.com/lib.js"
integrity="sha384-abcdef...长哈希..."
crossorigin="anonymous"></script>浏览器下载后会计算文件哈希,与 integrity 比对,不符就拒绝执行。这等于把"外部资源是否可信"从"我相信这个域名"升级成"我相信这段确切的字节"。代价是 CDN 一旦更新文件你就得同步换哈希,所以只对真正稳定、长期不变的外部资源使用。
Cookie 的安全属性则是后端的事,但对前端 XSS 影响巨大。如果会话 Cookie 没设 HttpOnly,注入的脚本就能用 document.cookie 把它偷走,发到攻击者服务器,再严的 CSP 也救不了已泄露的会话。所以我在 FastAPI 里强制会话 Cookie 带上这几个旗标:
from fastapi.responses import Response
resp.set_cookie(
key="session",
value=token,
httponly=True, # JS 读不到,断掉 XSS 偷 Cookie 的路径
secure=True, # 只走 HTTPS
samesite="lax", # 跨站请求不带 Cookie,缓解 CSRF
max_age=3600,
)SameSite=Lax 还能顺带缓解大部分 CSRF,因为它让跨站表单、跨站图片请求默认不带凭据。要更狠可以设 Strict,但会把"从外部链接点进来"的场景也拦掉,对个人博客的体验损失偏大,我选 Lax。
至于跨源隔离(Cross-Origin-Opener-Policy 和 Cross-Origin-Embedder-Policy),它能进一步锁死跨源窗口互相窥探,是高安全诉求站点才需要的进阶项。个人博客通常不必上,但知道它在哪一层、解决什么问题,等哪天你要嵌第三方、又要防 Spectre 类侧信道时,就知道该往哪发力。
nonce 的正确生成姿势
前面提到用 nonce 取代 'unsafe-inline',这里补一个容易写错的点:nonce 必须每次响应随机、且足够长(建议至少 16 字节),绝不能复用或写成常量。我的实现是在 middleware 里用 secrets 模块生成:
import secrets
def make_nonce() -> str:
return secrets.token_urlsafe(16)
# 在 dispatch 里把 nonce 注入 CSP 与模板上下文
nonce = make_nonce()
resp.headers["Content-Security-Policy"] = CSP.replace("__NONCE__", nonce)注意 nonce 要同时出现在响应头和 HTML 里,且两者必须一致;如果页面是服务端渲染,你得把同一个 nonce 传进模板引擎,而不是在前后端各生成一个。这个坑我踩过一次,前端框架自己生成了一个 nonce,和后端 CSP 头里的对不上,结果所有内联脚本全被拦截,页面一片空白。
把 SRI、Cookie 旗标、nonce 这些和 CSP 放一起看,你会发现它们共同构成了"默认不信任任何外部字节、也不信任自己页面里的任意脚本"的姿态。这正是 secure-by-default 的精髓:不是你主动挡了谁,而是系统本来就不轻易信任何人。
复盘:我那次加固的真实时间线
光讲原理容易,落到自己项目上往往是一地鸡毛。把我那次给博客加固的先后顺序复盘一遍,或许比任何原则都实在。我刻意把它拆成了"先观察、再收口、后隔离"三个阶段,而不是一口气全上。
第一阶段是开 Report-Only 观察两周。我把最宽松但正确的策略以 report-only 形式挂上去,期间共收到 47 条违规上报,其中 31 条来自我自己都忘了的第三方统计脚,它用了内联事件。剩下 16 条是构建产物里 Vue 运行时用到的 data: URI 字体。没有这一步,我直接上强制 CSP,博客大概率白屏,而我得花一下午猜是哪条资源被拦。
第二阶段是迁移内联代码。我把统计脚本换成带 nonce 的外部文件,把字体改成自托管、并进 img-src/font-src 白名单。这一步最磨人,因为要逐个改模板,但改完之后 script-src 'self' 才敢真正收紧,不再依赖任何 'unsafe-inline'。这里有个细节:Vue3 的运行时如果开启了某些特性可能注入内联样式,所以我保留 style-src 'self' 'unsafe-inline' 作为过渡,等后续把动态样式改成 class 切换后再去掉。安全加固是渐进的,别指望一步到位。
第三阶段才是 /file 沙箱化与全局响应头落地。上传目录的 sandbox 是最不需要犹豫的一刀,它对正常图片浏览体验零影响(附件下载本来就不是内联渲染),却彻底封死了"恶意 SVG 执行"的退路。配套的头(nosniff、DENY、Referrer-Policy)在同一处 middleware 统一注入,避免散落在各路由里忘写。
整个流程下来我最深的体会是:secure-by-default 不是"写一份完美策略然后部署",而是"用 report-only 先看见自己的不完美,再一点点把默认值掰正"。如果你现在就想动手,我建议的起步就是明天给项目挂一个 Report-Only 头,什么也别拦,先看两周上报,那 47 条违规,就是你系统真实的信任轮廓。
老实说,加固完的那天我反而更紧张了:因为终于有人(我自己)认真数过这个系统到底在信任谁。这种"知道自己暴露面"的踏实,比任何安全认证都值钱。
取舍与边界
写到这儿得泼点冷水。安全默认值的代价是"默认拒绝"会挡住一些你本来想放行的合理需求:第三方字体、CDN 上的库、嵌入的视频,每加一个都要在白名单上开一个口子,也就是多一道需要持续维护的信任关系。我的取舍是:能自托管就自托管(比如把字体和库打进构建产物),实在要引外部的,逐个显式声明来源,从不写 '*' 通配。
另一点边界:CSP 和上传隔离解决的是"降低破坏面",不是"消灭漏洞"。如果你的后端有 SQL 注入、有越权接口,前端再严也救不了你。安全默认值是地基上的护栏,不是地基本身。把地基(认证、授权、输入校验)做扎实,再用本文这些方法收紧默认值,才算把个人项目的安全水位抬到一个让自己睡得着觉的位置。
最后留一句给同样在维护个人项目的人:别等被黑了才想起加固。把"默认拒绝"当成出厂设置,比事后补一百个补丁都便宜。