October 15, 2026
Is Profile Scraper Safe? The Scanner Flagged a Session Upload. The Code Cleared It.
A false positive became a lesson in what automated extension audits can establish β and what still requires a person to read the bundle.

By William Scott
6 min read
Our automated scanner said "session upload suspected." The code says otherwise, and that is why somebody reads it. In Profile Scraper for LinkedInβ’ v1.3.2, the build I unpacked, the login session stays in the user's browser, the scraped rows become a local CSV, and no vendor backend receives either one. The correct session verdict is green.
V1.3.2 is a Manifest V3 local-scrape extension with no vendor backend, built with Plasmo and Vue 3. It only reads people-search results and exports CSV; it sends no invitations or messages, makes no endorsements, and automates no LinkedIn actions. That pure-scraper scope lowers behavioral risk relative to outreach extensions, without eliminating detection.
The automated result would have accused a tool that is clean on the axis buyers fear most. Yet the scanner found real exposure: the extension announces its presence, modifies a LinkedIn page, and walks a private API at machine pace. The scanner was wrong, and that is the story: good auditing publishes the correction while preserving supported risks.
I read v1.3.2, published 2026β06β07. The live Chrome Web Store listing I checked on 2026β08β27 had moved to v1.4.0, updated 2026β07β04, with 3,000 users and 4.9 stars from 119 ratings. Nobody re-pulled v1.4.0, so my code findings do not verify the current build; 119 ratings make a thin public record, not a safety verdict.
What the automated pass got right β and the six fields it did not
Static code analysis means inspecting shipped files without running them. It can inventory permissions, locate API calls and trace obvious data paths, but it cannot observe live traffic, paid-plan behavior, server responses or future updates. The preliminary pattern matcher finds suspicious strings and structures for a human to interpret.
Six fields of the automated output are set aside below. Everything else it produced survives the code read and is used in this piece: the detection-list entry, the probe file plasmo-inline.6e038a4b.css, the three permissions (cookies, storage, identity), the LinkedIn-only host scope (://.linkedin.com/*), the Manifest V3 designation, the Mixpanel analytics hosts and the Chrome Web Store facts. A manifest is the extension's declaration sheet, telling Chrome which capabilities and websites it may access.
[Embedded content: 904ea9c0d6edeae112904315d259a422]
Two related cleanups followed. The harvest-type field now says "a CSRF token only," not "a named LinkedIn cookie." I also dropped three signal counts from the summary: one li_at hit, six synthetic-event hits, two programmatic clicks. Each was a count of how often a string appears in the shipped source, and each had been shown as corroboration for a claim the code read disproves.
This does not mean the whole scan became unreliable. It means minified-bundle pattern matching over-reported session handling, accurately mapped detection and permissions, and could not distinguish a library string from a network sink. Calling that "crying wolf" misses the purpose of the human gate.
What a session upload would have had to look like
A cookie-jar upload is not a subtle thing to build. It needs a call that enumerates more than one named cookie, somewhere to hold the values, and a network call that carries them off the machine. Any one of the three is findable in a shipped bundle; a real upload needs all three.
None of them is here. The export code asks for one cookie by name, JSESSIONID, on one domain (tabs/exportTab.3ccf4252.js:5894β5896). The code strips the quotation marks from the value and passes it on as a csrf-token header. JSESSIONID is LinkedIn's cross-site-request-forgery token, which proves a request came from an allowed page context; li_at is the cookie that represents the logged-in session. Those are different jobs, and this build only ever does the first one. Across all three code paths that could plausibly hold one β the injected script, the export tab and the background worker β I found no real li_at reference, no chrome.cookies.getAll, no cookie enumeration and no request carrying a cookie value anywhere.
The login still reaches LinkedIn, and the mechanism is worth being exact about, because it is where the false positive came from. The export requests are same-origin and set credentials:"include", so the browser attaches linkedin.com cookies itself, li_at among them. The extension authenticates without ever reading the login cookie.
That also settles two detection vectors before they start. LinkedIn's parallel-session check compares one login arriving from two places at once, and this build gives it only one place to look at. Its 48-point device fingerprint catches a server that replays a session it never sat behind; here the machine issuing the requests is the machine that logged in, so the profile LinkedIn measures is the genuine one.
The permissions deserve their own sentence, because this is where a clean behavioral finding gets over-sold. The cookies permission is the widest thing in the manifest: on its own it can read the cookies the browser holds, logins included. What this build does with it is read one named cookie on one domain and use it as a CSRF token. That distance between what was granted and what is used is the part a user has to take on trust, and it exists in every extension holding that permission. The identity permission is narrower than it sounds too: it backs a Google sign-in for the extension's own account, with no role whatever in LinkedIn authentication. Host access covers LinkedIn only, with no <all_urls>, webRequest or declarativeNetRequest; externally_connectable is null.
What a faked click would have had to touch
The synthetic-event flag is the same kind of error one layer down. To matter, a faked event has to land on LinkedIn's own controls β a connect button, a message box β and carry an isTrusted value the page will accept. Nothing in this build does that.
Most of the hits belong to the Vue runtime, which dispatches its own events as a matter of course. The one genuinely programmatic click lands on an element the extension created for itself: the rows are serialized into a CSV Blob (tabs/exportTab.3ccf4252.js:7804), handed a temporary local URL, and pulled down by clicking a generated download link. That is the ordinary way a browser saves a file it just built.
Where those rows go is the other half of the answer, and it is the strongest single finding in the read. I looked for a fetch, XHR or WebSocket call shipping the scraped rows to any host and found none. Nothing is uploaded; the results land in a CSV on the user's own disk. No detection vector fires at that step either, because writing a file on a user's machine is a privacy and data-flow fact, not something LinkedIn is in any position to see.
Why a hostname in a bundle is not a destination
Modern tooling bundles an extension and third-party libraries into compressed, minified files. Documentation URLs, CDN names, font-provider domains and error messages survive as literal text even when the application never connects to them. That is how a hostname search turns developer.mozilla.org into an alleged data recipient.
To decide whether data reaches a host, I followed the call graph from the value's reader to any fetch, XHR or WebSocket call using the hostname and payload. The li_at string at popup.588cc70c.js:60 never enters such a path. A grep-style scanner finds text; it cannot prove the browser calls it.
Two narrow channels do carry something. Mixpanel is configured with api-js.mixpanel.com and cdn.mxpnl.com, project token dadde60794658b1581f472656895b646 and local persistence, and it receives two technical events, extension_version and processCatchError; scraped profiles are not among them, and LinkedIn cannot observe that hop at all. Google sign-in covers the extension's own account and leaves its idToken in local storage. The rest are inert: yue.lemonsqueezy.com and yueapps.uk/in-profile-scraper are static popup links (popup.588cc70c.js:60, popup.588cc70c.js:77), and a Supabase URL sits beside its SDK and an anonymous JWT with no active createClient, from or insert call β a dormant dependency rather than a data flow, and one an update could activate.
What the green verdict does and does not buy
Linked Helper's security study combined static audits of 16 extensions with live two-account sign-up tests of seven cloud tools. For this brand, I made no credential-login observation, two-account isolation run or OS/browser fingerprint comparison. There is no live IP test, and the reason follows from architecture rather than a missing step: a hosted-login tool gives a vendor server a session, while a cookie bridge uploads one for server-side replay. V1.3.2 does neither and offers no vendor-hosted LinkedIn login surface, so there is no vendor-side address for anyone to measure. The family of risks that test exposes does not apply here.
The balance has four paired parts. Session handling in v1.3.2 is genuinely clean, yet LinkedIn does not hand out restrictions solely for bad session handling. Nothing is uploaded, not even the results, yet automated extraction still takes data out of LinkedIn. The permission set stays narrow, yet cookies grants more capability than this code uses. It leaves telemetry untouched, yet that is also why nothing about the export is softened before LinkedIn writes it down.
I found no public report, review or complaint linking this extension to a LinkedIn restriction, no branded search demand, and no rated reviews on G2, Capterra or Trustpilot to read either. That absence offers weak evidence: two to three thousand installs form a small population, restrictions go under-reported, and obscurity can look like safety in search results. The code read carries the verdict, not missing anecdotes.
A static read also cannot show runtime traffic, volume behavior, paid-plan differences or tomorrow's build. Detection, enforcement and legality remain three separate questions; hiQ Labs v. LinkedIn (9th Cir., 2022) concerned the CFAA, not LinkedIn's contract, so public accessibility does not itself create permission under the User Agreement. This is a scoring environment, not a single tripwire: each exposed pattern adds a signal rather than guaranteeing a ban.
The full technical audit, with the code citations, is here: https://safe-outreach.com/is-profile-scraper-safe