Chrome DevTools Protocol network interceptor for MV3 extensions — with PII redaction, iframe auto-attach, and stale-debugger recovery.
Building a Chrome extension that needs to observe XHR/fetch responses is harder than it looks. The chrome.webRequest API hides response bodies entirely. The chrome.debugger API gives you bodies but ships with a pile of footguns:
- iframe traffic is invisible by default. Cross-origin iframes have their own CDP target. If you only attach to the top frame, you miss every API call inside an embedded checkout, payment, or analytics widget.
- stale attachments freeze the browser. When your service worker reloads (or crashes), Chrome leaves the old debugger session dangling. The next
attach()call rejects with "Another debugger is already attached" and the user has to manually close the tab to recover. - PII leaks into your logs. Raw response bodies contain emails, phone numbers, SSNs, and auth tokens. If you forward them to a backend or write them to disk, you've created a compliance liability.
- DevTools collides with your extension. If a developer opens DevTools while your extension is capturing, both grab the debugger socket and your capture silently dies.
This library wraps chrome.debugger.* with all of these problems already solved. You pass a tab id, a session id, and a callback. The library attaches, enables Target.setAutoAttach for iframe piercing, scrubs PII out of every response, and hands you a clean NetworkEvent. When the tab closes or your service worker reloads, it cleans up after itself.
It's domain-agnostic — there's no Medicare, sales, or fintech-specific code. You bring the patterns you care about (or use the defaults: SSN, email, phone, MBI, common auth headers).
npm install @axumquant/cdp-network-interceptorThis is an ES Module package. You need Node 18+ to consume it from a build pipeline. The runtime target is a Chrome MV3 extension service worker — chrome.* APIs are globals, not imports.
In your extension's background service worker:
import { startTrafficCapture, stopTrafficCapture } from "@axumquant/cdp-network-interceptor";
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.id) return;
await startTrafficCapture(tab.id, crypto.randomUUID(), {
onForward: (event) => {
// event.body is the parsed JSON, already redacted
console.log(event.method, event.url, event.statusCode, event.body);
},
});
});
chrome.tabs.onRemoved.addListener((tabId) => {
// (the library handles this automatically too — this is just illustrative)
stopTrafficCapture(tabId);
});Add "debugger" to your manifest.json permissions. That's it.
Attach to a tab and begin capturing network traffic.
| Parameter | Type | Description |
|---|---|---|
tabId |
number |
Chrome tab id. Must be a positive integer. |
sessionId |
string |
Caller-defined session id. Included on every forwarded event so you can route them downstream. |
options |
StartTrafficCaptureOptions |
See below. onForward is required; everything else has sensible defaults. |
| Field | Type | Default | Description |
|---|---|---|---|
onForward |
(event: NetworkEvent) => void | Promise<void> |
required | Called once per redacted event. Async callbacks are awaited but their rejections are swallowed. |
mode |
string |
"default" |
Free-form label echoed back in getCaptureStats(). Useful for distinguishing "copilot" vs "autopilot" sessions. |
iframeCapture |
boolean |
true |
When true, enables Target.setAutoAttach so cross-origin iframe traffic is also captured. |
redactionPatterns |
RedactionPatterns |
bundled defaults | Override the PII regexes. See PII redaction. |
maxPendingRequests |
number |
250 |
FIFO cap on tracked request ids — protects against memory bloat on chatty SPAs. |
maxBodySize |
number |
2 * 1024 * 1024 |
Drop response bodies larger than this many bytes. |
forwardableContentTypes |
string[] |
JSON-ish list | Content-type substrings to forward. Default: application/json, text/json, text/plain, application/javascript. |
skipUrlPatterns |
RegExp[] |
static-asset list | URLs matching any of these regexes are dropped before any body work. |
Returns a StartTrafficCaptureResult:
{
ok: true;
tabId: number;
sessionId: string;
mode: string;
recoveredStaleAttachment?: boolean; // true if we had to detach a zombie session
warnings?: string[]; // non-fatal startup issues (e.g. setAutoAttach unsupported)
alreadyCapturing?: boolean; // set when called twice for the same tab
}Throws a CaptureError (with a capture_phase of "validate", "attach", or "start") on failure.
Detach the debugger and stop forwarding events. Safe to call on a tab that isn't capturing.
Returns true if the library currently holds an active capture session for this tab.
Returns one entry per active session with eventCount, redactedFieldCount, pendingRequests, and uptime (in seconds).
The shape passed to your onForward callback:
interface NetworkEvent {
sessionId: string;
requestId: string;
url: string; // query values scrubbed
method: string;
statusCode: number;
headers: Record<string, string>; // sensitive header values scrubbed
body: unknown; // parsed JSON, redacted
frameOrigin: string | null;
targetId: string | null;
redacted: boolean;
redactedFieldCount: number;
}By default the library scrubs three categories of data:
Headers — any header matching this regex has its value replaced with [REDACTED]:
^(set-cookie|cookie|authorization|proxy-authorization|x-api-key|x-auth-token|x-session|x-csrf|x-xsrf)
Object keys — any key in the JSON body whose name matches the sensitive-key regex has its entire value replaced. Default coverage:
ssn, social, dob, date_of_birth, mbi, medicare, member_id, subscriber_id,
policy_number, phone, email, address, first_name, last_name, full_name,
client_name, patient_name
Inline values — string values that look like an email, SSN (xxx-xx-xxxx), phone number, or Medicare Beneficiary Identifier are replaced inline, even when they appear under an innocent-looking key.
Pass a redactionPatterns option built from the defaults:
import {
startTrafficCapture,
defaultRedactionPatterns,
} from "@axumquant/cdp-network-interceptor";
await startTrafficCapture(tabId, sessionId, {
onForward: handler,
redactionPatterns: {
sensitiveKey: new RegExp(
defaultRedactionPatterns.sensitiveKey.source + "|account_number|tax_id",
"i",
),
sensitiveHeader: defaultRedactionPatterns.sensitiveHeader,
// Add UK National Insurance numbers (must include `g` flag for replace())
sensitiveValue: new RegExp(
defaultRedactionPatterns.sensitiveValue.source +
"|\\b[A-Z]{2}\\d{6}[A-Z]\\b",
"gi",
),
},
});Important: the
sensitiveValueregex MUST have thegflag — the library usesString.prototype.replaceto scrub all occurrences.
When iframeCapture: true (the default), the library calls Target.setAutoAttach with flatten: true. Chrome then routes events from every nested target (cross-origin iframes, OOPIFs, workers) through the parent debuggee — your onForward callback sees them all without extra wiring.
Disable this only if you're certain you don't need iframe traffic and want to minimise overhead. Some embedded checkout flows (Stripe, Plaid, etc.) live entirely inside a cross-origin iframe and will be invisible without it.
Your manifest.json needs:
{
"manifest_version": 3,
"permissions": ["debugger", "tabs"],
"host_permissions": ["<all_urls>"]
}"debugger" is the only strictly required permission. "tabs" and a host permission are needed if you want to enumerate tabs or inject content scripts.
When you call chrome.debugger.attach(), Chrome shows a yellow infobar across the top of the tab: "Your Extension started debugging this browser." This is non-dismissible by users — it's Chrome's warning that an extension has elevated access. There's no way to suppress it.
Zombie debugger sessions on extension reload. If you reload your unpacked extension while a capture is active, Chrome leaves the old debugger attached. The library auto-recovers on the next startTrafficCapture call (and returns recoveredStaleAttachment: true), but you can also call chrome.debugger.getTargets() and detach manually on service worker startup.
Debugger collision with DevTools open. If a user opens DevTools on a tab you're capturing, Chrome refuses to attach a second debugger. The error message includes "Another debugger is already attached" — surface this to the user and ask them to close DevTools.
Performance on chatty SPAs. Each Network.getResponseBody call is a CDP round-trip. On a page firing 100 XHRs/sec, you'll feel it. Tune skipUrlPatterns and forwardableContentTypes aggressively to drop traffic you don't care about before the body fetch happens.
Service worker lifetime. MV3 service workers are killed after ~30s of idleness. The library's in-memory _sessions map dies with the worker. Persist session state to chrome.storage and re-establish capture on chrome.runtime.onStartup if you need long-running sessions.
Body size cap. Responses larger than maxBodySize (default 2 MiB) are silently dropped. If you're missing events, check whether you're trying to capture large payload responses (JSON dumps, file lists, etc.) and raise the cap.
JSON-only by default. Non-JSON responses (HTML, XML, binary) are skipped. Override forwardableContentTypes if you need them.
| Concern | Raw chrome.debugger |
This library |
|---|---|---|
| Attach + enable Network domain | manual (4+ lines) | one call |
| Stale attachment from extension reload | manual recovery | automatic, with recoveredStaleAttachment flag |
| Cross-origin iframe traffic | requires manual Target.setAutoAttach + per-target listeners |
automatic with iframeCapture: true |
| PII scrubbing | DIY | bundled defaults + injectable patterns |
| Request/response correlation | DIY | bundled (FIFO-capped pending map) |
| Tab close cleanup | DIY | automatic |
| Stats / observability | DIY | getCaptureStats() |
MIT — see LICENSE.