Chrome Extension 无法拦截 GET 请求或发送消息
结论
content.js 很可能已经注入页面,只是它运行在扩展的 isolated world(隔离环境)中。在这里修改 window.fetch 和 XMLHttpRequest.prototype,只会影响 content script 所在的 JavaScript 环境,网页脚本使用的 fetch 和 XMLHttpRequest 不会改变。
网页发起请求时,不会调用 content.js 中重写的函数。写在这些函数里的 chrome.runtime.sendMessage() 也就不会执行,service worker 因而收不到消息。
Sources 面板中能看到 content.js,通常说明它已经注入。日志没有出现,往往与 DevTools 的执行上下文、过滤条件或打开时机有关,不能据此判断脚本没有运行。
webRequest 和 activeTab 权限无法解决环境隔离。webRequest 也不能让 content script 自动进入网页的 JavaScript 上下文。
为什么重写 window.fetch 没有作用
Chrome Extension 的 content script 默认运行在 isolated world。它和网页可以访问同一个 DOM,但各自拥有独立的 JavaScript 全局对象和原型链。
下面的代码替换的是隔离环境中的 fetch:
const originalFetch = window.fetch;
window.fetch = async (...args) => {
// ...
};
网页脚本执行:
fetch('/api/data');
此时调用的是网页主环境里的 window.fetch,不会经过上面的包装函数。
XMLHttpRequest.prototype.open 和 XMLHttpRequest.prototype.send 也是如此。问题确实来自 page context 与 extension context 之间的隔离。代码虽然没有主动读取网页变量,却试图修改网页使用的 API。
为什么看不到 console.log
content script 和 service worker 属于不同的执行环境,日志位置也不同:
- 查看
background.js的日志,需要在chrome://extensions中点击该扩展的 “Service worker”。 - content script 的日志显示在目标网页的 DevTools Console 中。
- 运行在
MAINworld 的代码,其日志同样显示在网页 DevTools 中。
如果 Sources 中已经出现 content.js,可以逐项检查:
- 清除 Console 中的文本过滤条件。
- 确认已经启用
Info或Verbose日志级别。 - 关闭
Selected context only,或在 Console 的执行上下文下拉框中选择扩展对应的上下文。 - 打开 DevTools 后重新加载网页。
document_start阶段的日志可能早于 DevTools 打开的时间。 - 修改扩展后,先在
chrome://extensions中重新加载扩展,再刷新目标网页。
普通 content script 无法注入 chrome:// 页面、Chrome Web Store 等受保护页面。若目标是 file:// 页面,还要在扩展详情页中启用 “允许访问文件网址”。在无痕窗口中使用扩展也需要单独授权。
正确的实现方式
要修改网页脚本看到的 fetch,拦截代码需要在页面的 MAIN world 中运行。chrome.runtime.sendMessage 则只能由能够使用扩展 API 的隔离环境调用。
一般可以采用下面的通信结构:
网页 MAIN world
↓ window.postMessage
隔离环境中的 bridge.js
↓ chrome.runtime.sendMessage
background.js / service worker
在 manifest.json 中分别声明两个脚本:
{
"manifest_version": 3,
"name": "GET Request Interceptor",
"version": "1.0",
"background": {
"service_worker": "background.js"
},
"host_permissions": [
"<all_urls>"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["bridge.js"],
"run_at": "document_start",
"world": "ISOLATED"
},
{
"matches": ["<all_urls>"],
"js": ["page-interceptor.js"],
"run_at": "document_start",
"world": "MAIN"
}
]
}
能否使用 world 取决于扩展支持的 Chrome 版本范围。如果目标环境不支持这种声明方式,可以让 content script 创建 <script> 元素,将脚本注入页面。相应文件需要通过 web_accessible_resources 暴露。
MAIN world 中的 fetch 包装器
下面的示例只处理 GET 请求,并通过 window.postMessage 与隔离环境通信:
// page-interceptor.js
(() => {
const originalFetch = window.fetch;
const pending = new Map();
window.addEventListener('message', (event) => {
if (event.source !== window) {
return;
}
const message = event.data;
if (
!message ||
message.channel !== 'get-interceptor' ||
message.direction !== 'response'
) {
return;
}
const resolve = pending.get(message.id);
if (resolve) {
pending.delete(message.id);
resolve(message.response);
}
});
window.fetch = async function (resource, init = {}) {
const request =
resource instanceof Request
? resource
: new Request(resource, init);
if (request.method.toUpperCase() !== 'GET') {
return originalFetch.apply(this, arguments);
}
const id = crypto.randomUUID();
const responsePromise = new Promise((resolve) => {
pending.set(id, resolve);
});
window.postMessage(
{
channel: 'get-interceptor',
direction: 'request',
id,
request: {
type: 'intercept-fetch',
url: request.url,
method: request.method
}
},
location.origin
);
const response = await responsePromise;
if (!response || typeof response.status !== 'number') {
return originalFetch.apply(this, arguments);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
};
console.log('Page fetch interceptor installed');
})();
代码先创建 Request,因此可以处理绝对 URL、相对 URL、URL 对象以及已有的 Request 对象。原代码直接把 resource 发给 service worker。当 resource 是 Request 对象或相对 URL 时,这种处理容易出错。
实际项目中还要设置超时。如果扩展没有响应,应改为调用原始 fetch,以免消息丢失后网页请求一直停留在 pending 状态。
隔离环境中的消息桥
bridge.js 负责接收页面消息,再调用扩展 API:
// bridge.js
window.addEventListener('message', async (event) => {
if (event.source !== window) {
return;
}
const message = event.data;
if (
!message ||
message.channel !== 'get-interceptor' ||
message.direction !== 'request'
) {
return;
}
try {
const response = await chrome.runtime.sendMessage(message.request);
window.postMessage(
{
channel: 'get-interceptor',
direction: 'response',
id: message.id,
response
},
location.origin
);
} catch (error) {
console.error('Failed to contact service worker:', error);
window.postMessage(
{
channel: 'get-interceptor',
direction: 'response',
id: message.id,
response: null
},
location.origin
);
}
});
console.log('Extension message bridge installed');
网页脚本可能观察或伪造 window.postMessage 消息。生产代码应随机生成通道标识,并严格校验消息字段。这个通道也不应提供抓取任意 URL 的能力,否则网页可能利用扩展权限访问原本无法请求的地址。
调整 service worker
消息监听器应尽早同步注册。当前代码基本做到了这一点,还需要处理配置尚未加载完成的情况:
let validDomains = [];
let interceptRegex = null;
const configurationReady = Promise.all([
fetch(chrome.runtime.getURL('valid-domains.txt'))
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
})
.then((text) => {
validDomains = text
.split(/\r?\n/)
.map((domain) => domain.trim())
.filter(Boolean);
}),
fetch(chrome.runtime.getURL('intercept-file.txt'))
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
})
.then((text) => {
interceptRegex = new RegExp(text.trim());
})
]);
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message?.type !== 'intercept-fetch') {
return;
}
(async () => {
try {
await configurationReady;
const parsedUrl = new URL(message.url);
const response = await fetch(parsedUrl.href, {
method: 'GET'
});
const body = await response.text();
const domain = getRootDomain(parsedUrl.hostname);
const matches =
validDomains.includes(domain) &&
interceptRegex !== null &&
interceptRegex.test(parsedUrl.pathname);
sendResponse({
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: matches ? transformContent(body) : body
});
} catch (error) {
console.error('Request interception failed:', error);
sendResponse({
status: 502,
statusText: 'Extension request failed',
headers: {},
body: ''
});
}
})();
return true;
});
原实现可能在两个配置文件加载完成之前就收到消息。此时 interceptRegex 仍为 null,执行:
interceptRegex.test(path)
就会抛出异常。使用共享 Promise 等待初始化结束,可以避免这个竞态条件。
原代码中的其他问题
getRootDomain() 不适用于所有域名
按照最后两个标签截取域名,会把:
api.example.co.uk
处理成:
co.uk
如果域名列表包含公共后缀下的注册域,应使用基于 Public Suffix List 的解析库,或直接比较完整的 hostname。若需求只是匹配明确列出的主机名,比较完整主机名更简单,也更可靠。
service worker 发出的请求不是原网页请求
在 service worker 中重新执行:
fetch(url)
会创建一条新请求,未必能保留原请求中的这些信息:
- Cookie 和凭据行为
Authorization等请求头- 请求发起者的
Origin、Referer - 缓存及重定向状态
Request对象中的其他选项
这可能导致服务器返回不同的结果。即使扩展拥有 host_permissions,也不代表它完整重放了原网页请求。
请求参数不一定能通过扩展消息传递
Headers、流式 body 和某些 Request 字段不能直接作为普通对象传递。发送消息前,需要将它们转换成可序列化的数据,例如:
const headers = Object.fromEntries(request.headers.entries());
这里仅拦截 GET,请求体通常不需要传递。
不能直接伪造原生 XHR 状态
下面这些属性通常由浏览器管理,并不是普通的可写字段:
this.status = response.status;
this.statusText = response.statusText;
this.readyState = 4;
直接赋值无法可靠地把原生 XMLHttpRequest 改成已完成状态,responseText 也可能无法按预期重新定义。完整代理 XHR 需要实现包装类,还要处理事件、超时、终止、响应类型、上传进度和同步请求等行为,比代理 fetch 复杂得多。
如果目标站点可以调整,建议只支持 fetch。必须兼容 XHR 时,可以让 MAIN-world 代码把 XHR 请求改写成 fetch,或实现完整的 XHR 代理,不要尝试修改现有实例的只读状态。
是否应该使用 webRequest
应根据实际需求选择方案:
- 只需观察请求,可以使用
chrome.webRequest,无需重写网页的fetch。 - 需要阻止或重定向请求,Manifest V3 通常使用
declarativeNetRequest。 - 需要修改服务器响应正文,
webRequest本身无法直接完成这套 content script 响应替换逻辑。可以代理请求、重定向到自有资源,或在页面主环境中包装相关 API。 - 只需修改自家接口返回的数据,从服务器、测试代理或应用代码层处理通常更稳妥。
当前 manifest 并不缺少某项能让现有代码直接生效的权限。问题首先出在 JavaScript 执行环境隔离,之后还要处理初始化竞态、URL 规范化、请求语义和 XHR 只读状态。
备注:内容仅供参考。
