宣告Gloria项目的死亡 #41
Description
失败的Manifest V3迁移之旅
Gloria是建立在Manifest V2之上的浏览器扩展程序, Manifest V2现已被Manifest V3替代, 最终会失去浏览器支持, 详见Chrome的Manifest V2支持时间表.
在尝试将Gloria从Manifest V2迁移至Manifest V3的过程中, 我们遇到了无法克服的障碍, 这导致迁移无法完成, 项目因此走向终结.
Offscreen Documents + Web Workers
Manifest V3的Service Worker限制了执行动态代码的能力, 因此我们需要通过offscreen document来绕过限制.
在offscreen document里, 存在一个奇妙的例外允许执行动态代码, 尚不确定这是否属于安全漏洞.
借助这一例外, 仍然不足以运行预期中的Gloria脚本, 因为Worker无法导入外部模块(原本使用内置模块gloria-utils
的做法因为无法实现对依赖项的版本控制, 遭到废弃).
尝试1:
const script = `import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.
Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.
尝试2:
const script = `import('https://esm.sh/@blackglory/wait-for@0.7.4')`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.
Refused to create a worker from 'https://esm.sh/@blackglory/wait-for@0.7.4' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'worker-src' was not explicitly set, so 'script-src' is used as a fallback.
尝试3:
import { javascript } from 'extra-tags'
const script = esm(`import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`)
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url)
function esm(code) {
return javascript`
loadESMScript(${code})
async function loadESMScript(script) {
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
await import(url)
URL.revokeObjectURL(url)
}
`
}
Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/9fa8785b-5e3f-42fd-86df-7b845f443070' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.
loadESMScript @ 321e052c-f1af-4bad-9d77-41d771f9e83e:6
321e052c-f1af-4bad-9d77-41d771f9e83e:6 Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/9fa8785b-5e3f-42fd-86df-7b845f443070' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.
尝试4:
import { javascript } from 'extra-tags'
const script = esm(`import('https://esm.sh/@blackglory/wait-for@0.7.4')`)
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url)
function esm(code) {
return javascript`
loadESMScript(${code})
async function loadESMScript(script) {
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
await import(url)
URL.revokeObjectURL(url)
}
`
}
Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/e13f475e-4dbd-4dec-811e-cd417d0a37b5' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.
loadESMScript @ 1b0b39d4-517b-42b2-af32-be06cf3be884:6
1b0b39d4-517b-42b2-af32-be06cf3be884:6 Refused to load the script 'blob:chrome-extension://hjbedkekcmmaclhccpicpjbkbhjniblj/e13f475e-4dbd-4dec-811e-cd417d0a37b5' because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.
实测表明针对性修改CSP也没用.
尽管我们确实可以在Worker里正常访问外部资源:
fetch('https://blackglory.me')
.then(res => res.text())
.then(console.log)
但这只能满足最低限度的Gloria脚本用例.
例如, 你可能会需要JSDOM, 因为你需要DOMParser来解析HTML或XML(原生Web Workers环境里并不存在DOMParser).
总之, 直接在offscreen document里执行动态代码的做法并不怎么靠谱:
- 导入外部模块的能力受到限制, 无法创建具有外部依赖项的脚本.
- 相关"特性"处于灰色地带, 随时有可能被"修复", 或者利用相关"特性"的扩展程序会被阻止上架Chrome Web Store.
特别值得一提的是, Chrome官方认可的用户脚本现在要求扩展程序的用户手动启用开发者模式, 因此不授权就执行用户脚本的做法很可能是违规的.
Offscreen Documents + Iframe + Web Workers
Manifest V3实际上也有正规的执行不安全代码的方法, 即从Manifest V2就有的基于iframe的沙盒.
对于Gloria的用例, 需要在offscreen document里创建和使用基于iframe的沙盒.
最初, 我对此方案很有信心, 毕竟官方已经给出了执行不安全代码的方法, 还能出什么错呢?
尝试1:
fetch('https://blackglory.me')
.then(res => res.text())
.then(console.log)
Access to fetch at 'https://blackglory.me/' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
原因在于iframe的origin是null
, 因此不具有扩展程序的跨域能力, 在manifest.json
里声明的host_permissions
对iframe来说没有任何意义.
理论上, 可以通过为iframe启用allow-same-origin
来使其获得与扩展程序相同的origin, 从而获得跨域能力.
尝试2:
const script = `import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to cross-origin redirects of the top-level worker script.
尝试3:
const script = `import('https://esm.sh/@blackglory/wait-for@0.7.4')`
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url, { type: 'module' })
Refused to cross-origin redirects of the top-level worker script.
尝试4:
import { javascript } from 'extra-tags'
const script = esm(`import { waitForTimeout } from 'https://esm.sh/@blackglory/wait-for@0.7.4'`)
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
new Worker(url)
function esm(code) {
return javascript`
loadESMScript(${code})
async function loadESMScript(script) {
const blob = new Blob([script], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
await import(url)
URL.revokeObjectURL(url)
}
`
}
正常运行, 至少我们有一种方式可以导入带有CORS header的外部模块.
尝试在manifest.json
里添加allow-same-origin
来解决跨域问题:
"content_security_policy": {
"sandbox": "sandbox allow-scripts allow-same-origin;"
}
Invalid value for 'content_security_policy.sandbox'.
在HTML的iframe的sandbox属性上添加allow-same-origin
则会静默失败.
显然, Chrome有意阻止为Sandbox启用allow-same-origin
选项.
其中一个原因可能是同时启用allow-scripts
和allow-same-origin
能让沙盒内的代码逃逸.
至此我们陷入一个奇怪的局面:
- 在offscreen document里不能导入外部模块, 但能访问任意外部资源.
- 在iframe里不能访问任意外部资源, 但能导入外部模块(尽管是CORS限定, 但也够用了).
一种解决方案是在offscreen document里向iframe暴露一个API, 使其能够访问任意外部资源.
这意味着对fetch
, EventSource
, WebSocket
这样的Web API进行包装.
此方案的实施难度大, 兼容性差, 其中一些数据类型很可能无法在上下文之间复制或转移, 不可行.
另一种解决方案是通过Manifest V3臭名昭著的DNR为响应添加CORS header, 从而绕过跨域限制.
然而, DNR的过滤条件无法匹配到由扩展程序沙盒发出的来自opaque origin的请求.
理想状态下, 这应该适用于沙盒, 可惜它没有:
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1]
, addRules: [
{
id: 1
, condition: {
initiatorDomains: [chrome.runtime.id]
}
, action: {
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS
, responseHeaders: [
{
operation: chrome.declarativeNetRequest.HeaderOperation.SET
, header: 'Access-Control-Allow-Origin'
, value: '*'
}
]
}
}
]
})
这适用于沙盒, 但影响了浏览器内的所有请求, 引入巨大的安全问题:
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1]
, addRules: [
{
id: 1
, condition: {}
, action: {
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS
, responseHeaders: [
{
operation: chrome.declarativeNetRequest.HeaderOperation.SET
, header: 'Access-Control-Allow-Origin'
, value: '*'
}
]
}
}
]
})
另一方面, 很难用DNR维持Gloria现有的Cookie
, Referer
, Origin
动态注入能力, 这破坏了Gloria订阅私人消息的用例.
对Gloria的事后验尸
我在Gloria上的大多数技术决策都受到开发Gloria时的时代局限, 在这方面我不认为有做错什么.
在开发Gloria时, JavaScript被CoffeeScript替代, 因此我选择用CoffeeScript的超集LiveScript来开发, ESM支持和ESM CDN不存在, 流行模块标准至少有三个, 大多数MVVM都被Angular带上了双向数据绑定的弯路, TypeScript则根本没几个人使用.
如今JavaScript已经发展到ES2023, 我们有了原生的ESM支持, 有像https://esm.sh这样的ESM CDN,
有React这样成熟的MVVM框架, 并且大多数仍被使用的npm模块要么用TypeScript重写, 要么具有TypeScript类型定义.
现有的Web技术是当年难以想象的, Gloria项目最失败的部分是没有跟上Web技术的步伐,
这都是因为我在开发Gloria时没有采用一个易于维护的架构.
当Gloria的代码逐渐变得陈旧, 任何大的改变都需要以重写的形式来实现时, 项目的发展理所当然地停滞了.
最终, 重写没有到来, 到来的是Manifest V3替代Manifest V2的历史车轮.
这是Gloria原本预定实装的新脚本格式, 对想要开发类似项目的开发者也许会有参考价值:
// -- 此脚本的各种元数据, 语法类似于油猴脚本 --
// @name 脚本显示的名称
// @update-url 脚本的更新地址
// -- 导入外部ESM模块 --
// -- 其他只在创建Worker时运行一次的代码 --
// -- 作为ESM模块的默认项返回, 执行器将会根据返回值类型决定是否采用轮询方式 --
export default function (signal: AbortSignal):
| INotification[]
| PromiseLike<INotification[]>
| Observable<INotification>
| AsyncIterable<INotification>
接下来会发生什么?
- 随着Manifest V2的生命周期走向终结, 你无法在Chromium浏览器上继续下载、安装、使用Gloria.
你可以在旧版本的Chromium里继续使用Gloria, 但这注定无法长期维持下去.
作为用户, 你可以尝试转去使用Gloria的开源替代项目Gloria-X,
Gloria-X很可能最终会面临与本项目类似的问题, 但也许相关功能可以在Firefox上延续下去. - 该项目的源代码存储库会转入归档状态, 仅提供代码下载功能, 直到未来某一天我决定删除它.
作为开发者, 你可以转去为Gloria的开源替代项目Gloria-X做贡献. - https://gloria.pub网站将会下线, 服务器源代码将被删除, 数据库将被删除, 域名将停止续费.
- 该项目依赖项的源代码存储库, 例如worker-sandbox和gloria-sandbox将被删除, 你仍然可以在npm里下载这些依赖项.
如果你需要在Web Workers里动态定制沙盒环境, 我相信delight-rpc/browser是更好的解决方案. - 我会转去尝试开发在浏览器环境以外运行的替代解决方案.
脱离浏览器环境的解决方案注定不会有Gloria这样的集成度, 我相信它不会适用于绝大多数现有的Gloria用户.
事情还会有转机吗?
一旦我开始开发替代解决方案, 就不再可能会有转机, 因为我不能同时维护复数服务于相似目的的项目.