Writing

Inspecting extension probes in LinkedIn’s browser code

A browser investigation of extension resource probes, tracking payloads, and the limits of what the code proves.

This investigation started with some odd-looking requests in the browser’s developer tools: why was LinkedIn trying to load resources from Chrome extensions?

I followed the requests back to the code that made them. These are my notes from that browser session on 16 May 2026, written as a companion to my talk on dark patterns. They show what I found in that session; they don’t tell us what every visitor receives or what happens to the data afterwards.

Reproducing the observation

In a Chromium browser, I opened the developer tools on linkedin.com/feed and inspected requests to extension resources. Failed requests included:

GET chrome-extension://invalid/   net::ERR_FAILED
GET chrome-extension://<32-char-id>/<some-file>   net::ERR_FAILED

Following a request’s Initiator led to the code making the probe. A failed request alone is not proof of a particular extension being installed. The interesting evidence is the combination of the resource list, the probing function, and what it does with resolved requests.

The probing function

The function below is an excerpt from the client bundle I inspected:

async function l(e) {
  let t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {},
    n = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {};
  if (!a() || !s()) return;
  const { useRequestIdleCallback: i = !1, timeout: r = 2e3, staggerDetectionMs: l = 0 } = n,
    d = async () => {
      const n =
        l > 0
          ? await (async function (e) {
              const t = [];
              for (const { id: n, file: i } of o) {
                try {
                  (await fetch(`chrome-extension://${n}/${i}`)) && t.push(n);
                } catch (e) {}
                e > 0 && (await new Promise((t) => setTimeout(t, e)));
              }
              return t;
            })(l)
          : await c();
      Array.isArray(n) &&
        n.length > 0 &&
        e.fireTrackingPayload('AedEvent', {
          browserExtensionIds: n,
          ...t,
        });
    };
  i && 'function' == typeof window.requestIdleCallback
    ? window.requestIdleCallback(d, {
        timeout: r,
      })
    : await d();
}

The loop reads { id, file } entries and calls fetch() on a chrome-extension:// URL. Resolved fetches add an ID to a results array. An optional delay spaces out requests. Two predicates, a() and s(), determine whether the function runs; their meaning is not established by this excerpt.

When the results array is non-empty, the code calls fireTrackingPayload() with an event named AedEvent and a browserExtensionIds field. This shows how the function packages detected identifiers. It does not, by itself, explain the subsequent processing or retention of those identifiers.

The input array contained entries in this form:

{ id: "ppkpmdanppmnndopfcnpjaefkkjjiljc", file: "linkedin-overlay.95bad065.css" },
{ id: "pplkiigdcpbacikbalacopmaklichaod", file: "logo16.png" },
{ id: "pplplkmiajmkgdhjhpcibkkfkmofmcdp", file: "icons/favicon.png" },
{ id: "ppmjcikinhahlcldpndeakomkfpfedno", file: "slide-lab.html" },
{ id: "pppndnondekehijelkdnlihcfehjacfe", file: "jc.98ee31a0.css" }

Each entry pairs an extension ID with a resource path. A file path can become stale when an extension changes, so a failed probe is not conclusive evidence that an extension is absent.

Testing one listed extension

I selected a listed distraction-blocking extension, installed it, and refreshed the feed. The corresponding resource request then resolved where it had previously failed:

chrome-extension://ppdjgkniggbikifojmkindmbhppmoell/icons/icon128.png

The initiator chain returned to the same probing function in a LinkedIn asset bundle. That change supported the interpretation that the code was testing whether a known extension resource was reachable.

The mechanism depends on the extension exposing a resource to the requesting page. Chrome extensions can declare web_accessible_resources, with rules controlling which origins can load them. Browser behaviour, the manifest, the allowed origins, and the resource path all matter.

Chrome’s documentation on web-accessible resources explains those rules. The probe uses resource requests rather than the permission-gated chrome.management API.

A separate collection endpoint

The same session also included requests to this endpoint:

Request URL: https://collector-pxdojv695v.protechts.net/api/v2/msft

A separate bundle configured its domains through the following function:

function Fu() {
  try {
    var t = ['protechts.net'];
    Fv(t) && (Fn[mr] = t);
  } catch (t) {}
  try {
    var n = ['/api/v2/msft'];
    Fv(n) && (Fn[ms] = n);
  } catch (t) {}
  try {
    var e = ['px-client.net', 'px-cdn.net'];
    Fv(e) && (Fn[mt] = e);
  } catch (t) {}
  try {
    var r = ['/assets/js/bundle', '/res/uc'];
    Fv(r) && (Fn[mu] = r);
  } catch (t) {}
  try {
    var h = ['/b/c'];
    Fv(h) && (Fn[mv] = h);
  } catch (t) {}
}

The names and endpoint shape are consistent with the HUMAN / PerimeterX sensor infrastructure. This is a separate observation from the extension probes; the code excerpts do not establish a shared purpose for the two mechanisms.

The function below assembled browser and device properties:

function Cp(t) {
    var n = (function(){ try { return hT[hR(511)] && hT[hR(511)][jc("bWVtb3J5")] } catch (t) {} })();
    n && (t["BFAxGkIwMSE="] = n[jc(hR(806))],
          t[hR(807)] = n[jc("anNIZWFwU2l6ZUxpbWl0")],   // "jsHeapSizeLimit"
          t[hR(808)] = n[jc(hR(809))]);
    try {
        t[hR(812)] = !!hT.Buffer,
        t[hR(813)] = hT.orientation,
        t["W0suQR0oJHc="] = !!hT.v8Locale,
        t["KDRdPm5ZXg4="] = !!hT.ActiveXObject,
        t[hR(822)] = kA(hT.openDatabase),
        t[hR(827)] = hT[hR(151)]("ontouchstart") || "ontouchstart" in hT,
        t[hR(828)] = kA(hT.BatteryManager) || kA(hV[hR(829)]) || kA(hV.getBattery),
        t[hR(830)] = hT[hR(511)] && hT[hR(511)].navigation && hT[hR(511)][hR(831)].type,
        // ...pointer:fine media query, fonts via jU(), and ~30 more
    } catch (t) {}
}

Decoded keys include memory and jsHeapSizeLimit. The code also checks browser capabilities, touch support, navigation properties, and other environment signals. I paused execution before a request was transmitted and inspected this partial payload:

{
  "ts": 1778962041611,
  "dydCbTFGQ14=": 1778962040859,
  "BFAxGkIwMSE=": 8096937,
  "FwdiDVFnZTo=": 4294967296,
  "EwNmCVVuZzg=": 12762493,
  "JnZTfGAbVU4=": "Sat May 16 2026 22:07:26 GMT+0200 (Central European Summer Time)",
  "DXl4M0gfeQQ=": true,
  "eEQNDj4iCzU=": "hidden",
  "MDxFNnVYQgQ=": 1280,
  "JxcSHWF0FS4=": 752,
  "GUVsT18oaHo=": "missing",
  "IxMWGWZyFyo=": 2
}

These values describe the captured session, including memory properties, a timestamp with a time zone, visibility, and viewport dimensions. A collection of such signals can contribute to fingerprinting. This sample does not measure whether the combination uniquely identifies a person.

What the evidence supports

  • The inspected bundle contains a conditional function that probes known extension resource URLs.
  • It packages identifiers from resolved requests into an AedEvent payload.
  • Installing one listed extension changed the observed response to its resource probe.
  • A separate sensor assembled browser and device properties in the same session.

Those observations are narrower than a claim about all LinkedIn users, all browser extensions, or every use of the resulting data. Browser code changes, so a later reproduction may differ.

The code excerpts and payload above are retained from my original inspection. They are presented as evidence to examine, rather than code to run on another person’s browser.