PAYATHON 2026

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 中。
  • 运行在 MAIN world 的代码,其日志同样显示在网页 DevTools 中。

如果 Sources 中已经出现 content.js,可以逐项检查:

  1. 清除 Console 中的文本过滤条件。
  2. 确认已经启用 Info 或 Verbose 日志级别。
  3. 关闭 Selected context only,或在 Console 的执行上下文下拉框中选择扩展对应的上下文。
  4. 打开 DevTools 后重新加载网页。document_start 阶段的日志可能早于 DevTools 打开的时间。
  5. 修改扩展后,先在 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 只读状态。

备注:内容仅供参考。