Is PhantomBuster safe?
PhantomBuster is medium risk. PhantomBuster maps LinkedIn profile data into lead-storage and contact-discovery requests to api.phantombuster.com after side-panel actions like saving a profile. Unauthenticated analysis missed these POSTs; needs a login and panel action.…
Who publishes itPhantombuster - 1 other listing from the same operator, none carrying a finding
Phantombuster - 1 other listing from the same operator, none carrying a finding
What this publisher told the store about itself, and the other listings that told it the same thing.
Same operator - 1 listing
Published under a different store account, but sharing the registered address, contact or declared legal entity this one gave the store.
Shared hosts - 1 hostname
Hostnames hardcoded in this extension that few other listings call. That can mean one operator behind both, and it can equally mean a small shared vendor, so it is context rather than a conclusion. Hosts that many listings call are left out: they are services, not connections.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
LinkedIn lead details posted to PhantomBuster APIs
PhantomBuster maps LinkedIn profile data into lead-storage and contact-discovery requests to api.phantombuster.com after side-panel actions like saving a profile.
Unauthenticated analysis missed these POSTs; needs a login and panel action.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You save a LinkedIn profile or request contact info from the PhantomBuster side panel.
The unauthenticated test session did not reach this flow.
The extension posts profile-derived fields to PhantomBuster lead-storage or contact-discovery APIs.
The code adds browser-extension headers and can add the current PhantomBuster organization identifier.
- NameJane Doe (illustrative)
Identifies the LinkedIn profile being saved or researched.
- LinkedIn profile URLhttps://www.linkedin.com/in/jane-doe-123456/
Links the saved lead or contact lookup to a specific LinkedIn profile.
- Company and role contextAccount Executive at Example Corp (illustrative)
Describes where the person works and the role shown on the profile.
- Contact-discovery inputsfirst_name=Jane, last_name=Doe, company=Example Corp (illustrative)
Supplies the name, company, LinkedIn URL, and company LinkedIn URL used for contact lookup.
- PhantomBuster organizationorg_123456 (illustrative)
Associates the request with the PhantomBuster workspace selected in the browser.
Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a lead-save POST.
- X-Phantombuster-Org
- org_123456 (illustrative)
- x-phantombuster-browser-extension
- Chrome
- x-phantombuster-browser-extension-version
- 1.3.9
Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a discover-email POST.
- Content-Type
- application/json
- X-Phantombuster-Org-Name
- Example Workspace (illustrative)
- x-phantombuster-browser-extension
- Chrome
- x-phantombuster-browser-extension-version
- 1.3.9
Profile fields mapped into PhantomBuster API requests
Source-map lead-save path
src/sidepanel/services/pbV2.ts, lib/leads.ts, hooks/useCreateLeadFromCurrentTab.tsexport async function getCurrentOrgIdFromCookies() { const cookies = await getCookies() const baseDomain = new URL(import.meta.env.VITE_PHANTOMBUSTER_BASE_URL).hostname const currentOrg = findCookie(cookies, CURRENT_ORG_ID_COOKIE, baseDomain) return currentOrg?.value ?? ""}export const apiV2Client = createClient<paths>({ baseUrl: new URL("/api/v2", import.meta.env.VITE_PHANTOMBUSTER_API_URL).toString(), headers: { "x-phantombuster-browser-extension": getBrowserName(), "x-phantombuster-browser-extension-version": getExtensionVersion(), },})const middleware: Middleware = { async onRequest({ request }) { const orgIdHeader = "X-Phantombuster-Org" const currentOrgId = await getCurrentOrgIdFromCookies() const hasOrgIdHeader = request.headers.has(orgIdHeader) const shouldReplaceOrgIdHeader = !hasOrgIdHeader && currentOrgId if (shouldReplaceOrgIdHeader) { request.headers.set(orgIdHeader, currentOrgId) } if (import.meta.env.MODE === "development") { // eslint-disable-next-line no-console console.info(`[ApiPbV2Client] Making ${request.method} request to: ${request.url}`) // eslint-disable-next-line no-console console.info(`[ApiPbV2Client] Headers:`, Object.fromEntries(request.headers)) } return request },}apiV2Client.use(middleware)/** Upsert a lead */export const saveLead = async (body: SaveLeadsPayload, init?: RequestInit) => apiV2Client.POST("/org-storage/leads/save", { body, signal: init?.signal, })export function useMutationSaveLead() { return useMutation({ mutationKey: ["/org-storage/leads/save"], mutationFn: saveLead, onError() { toast.error("We couldn’t save the profile. Try again in a bit.", { dismissible: true, }) }, })}export const mapProfileToLead = (profile: Profile): Partial<SaveLeadsPayload> => ({ firstName: profile.firstName, lastName: profile.lastName, linkedinJobLocation: profile.location?.name, linkedinHeadline: profile.headline, location: profile.location?.name, companyName: profile.jobExperiences?.[0]?.name, linkedinCompanyUrl: profile.jobExperiences?.[0]?.linkedinUrl, linkedinDescription: profile.summary, previousCompanyName: profile.jobExperiences?.[1]?.name, linkedinJobTitle: profile.jobExperiences?.[0]?.jobTitle, personalEmails: profile.personalEmail ? [profile.personalEmail] : [], professionalEmails: profile.professionalEmail ? [profile.professionalEmail] : [], phoneNumbers: profile.phoneNumbers ? [...profile.phoneNumbers] : [],})/** Save current Linkedin profile to the leads database */export const useCreateLeadFromCurrentTab = () => { const { slug } = useParams<{ slug: string }>() const { mutateAsync: saveLead, isIdle, error } = useMutationSaveLead() const { data: tab } = useSuspenseTab() const { profile } = useSuspenseProfileAndCompany(slug ?? "") const createLead = async () => { return saveLead({ linkedinProfileUrl: resolveMeLinkedinSlugToFullUrl({ tabUrl: extractLinkedInSlugUrl(tab?.url), profileSlug: profile?.slug, }), ...mapProfileToLead(profile), }) } return { createLead, createLeadIdle: isIdle, createLeadError: error, }}Source-map contact-discovery path
src/sidepanel/services/pbV1.ts and lib/email-discovery.tsxexport async function discoverProfileEmailAndPhone( orgName: string, body: { payload: EmailDiscoveryPayload | EmailDiscoveryPayload[] }, init?: RequestInit,) { const res = await apiV1Client.POST<EmailDiscoveryResponse>("/discover-email", { body: Object(body), headers: { "X-Phantombuster-Org-Name": orgName, }, signal: init?.signal, }) if (res.err) { console.error(res.err) throw res.err } return res.data.data}/** * Unfortunatly the v1 api does not have type definition so we will use it without openapi-fetch */export const apiV1Client = { async request<T>( method: string, endpoint: `/${string}`, options: RequestInit = {}, ): Promise< | { data: T; err?: never } | { err: Error data?: never } > { options.headers = { ...options.headers, "Content-Type": "application/json", "x-phantombuster-browser-extension": getBrowserName(), "x-phantombuster-browser-extension-version": getExtensionVersion(), } options.method = method if (options.body) { options.body = JSON.stringify(options.body) } return fetch(new URL(`/api/v1${endpoint}`, import.meta.env.VITE_PHANTOMBUSTER_API_URL), options) .then(async (res) => { if (!res.ok) { throw new Error(await res.text()) } return { data: (await res.json()) as T } }) .catch((err: Error) => { console.error(err) return { err } }) }, GET<T>(endpoint: `/${string}`) { return this.request<T>("GET", endpoint) }, POST<T>(endpoint: `/${string}`, options: RequestInit) { return this.request<T>("POST", endpoint, options) }, PUT<T>(endpoint: `/${string}`, options: RequestInit) { return this.request<T>("PUT", endpoint, options) }, DELETE<T>(endpoint: `/${string}`) { return this.request<T>("DELETE", endpoint) }, PATCH<T>(endpoint: `/${string}`, options: RequestInit) { return this.request<T>("PATCH", endpoint, options)export function useEmailAndPhoneDiscovery({ orgName, profile }: { orgName: string; profile: Profile }) { const { data, error, isPending, mutateAsync } = useMutationDiscoverProfileEmailAndPhone(profile.slug ?? "") const errorBoundary = useErrorBoundary() const discoverMutationKey = [DISCOVER_PROFILE_EMAIL_AND_PHONE_KEY, profile.slug] const [mutationStatus] = useMutationState({ filters: { mutationKey: discoverMutationKey }, select: (mutation) => mutation.state.status, }) async function fetchContactInfo() { errorBoundary.resetBoundary() const company = findProfileMainCompany(profile) try { await mutateAsync({ orgName, payload: { first_name: profile.firstName, last_name: profile.lastName, company: company?.name, linkedin: constructLinkedinProfileUrlFromPublicIdentifier(profile.slug), company_linkedin: company?.linkedinUrl, }, }) } catch (err) { errorBoundary.showBoundary(err) } } return { data, error, isLoading: isPending || mutationStatus === "pending", fetchContactInfo, }}- api.phantombuster.com
Receives lead-storage and contact-discovery requests generated from LinkedIn side-panel actions.
LinkedIn profile data requested with the user session
Using PhantomBuster on a LinkedIn profile makes it read the session cookie, use it as CSRF token for Voyager GET requests, and return data to the side panel.
Unauthenticated analysis didn't observe this; needs a logged-in account and panel.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You open PhantomBuster on a LinkedIn profile or search result.
The unauthenticated test did not trigger the flow; the code path is tied to side-panel use.
The extension reads the LinkedIn cookie value and requests LinkedIn Voyager data as your browser session.
The background script handles profile, company, identity, and search request types.
- LinkedIn session-derived tokenajax:1234567890123456789 (illustrative)
Lets the request use your logged-in LinkedIn browser session for the profile-data request.
- Profile identifierjane-doe-123456
Identifies which LinkedIn member profile the side panel is requesting.
- Profile and company dataJane Doe, Account Executive at Example Corp (illustrative)
Describes the person or company shown in the side panel.
- Search result path/search/results/people/?keywords=security%20engineer
Can describe LinkedIn search results being processed when the request type is search.
Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a live Voyager request.
- csrf-token
- ajax:1234567890123456789 (illustrative)
- x-restli-protocol-version
- 2.0.0
Cookie extraction and Voyager request handling in the shipped background bundle
Source-map TypeScript for the same path
src/shared/voyager.ts and src/background/background.tsexport const findLinkedInCsrfToken = (cookies: Cookies.Cookie[]) => { // The CSRF token is required to make authenticated calls to the LinkedIn API and is stored in the "JSESSIONID" cookie for some reason // The JSESSIONID cookie takes the following form: "ajax:<random number>" // The extra quotes need to be removed before it can be used const csrfToken = cookies .find((cookie) => cookie.domain === ".www.linkedin.com" && cookie.name === "JSESSIONID") ?.value?.replace(/"/g, "") return csrfToken ?? null}async function sendVoyagerRequest<TResponse>(endpoint: `/${string}`, csrfToken: string): Promise<TResponse> { try { const response = await fetch(`https://www.linkedin.com/voyager/api${endpoint}`, { headers: { "csrf-token": csrfToken, "accept-language": "en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7", "sec-ch-ua": '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "x-li-lang": "en_US", "x-li-track": '{"clientVersion":"0.2.*","osName":"web","timezoneOffset":2,"deviceFormFactor":"DESKTOP","mpName":"settings-web","displayDensity":1}', "x-restli-protocol-version": "2.0.0", }, referrer: "https://www.linkedin.com/feed/", referrerPolicy: "strict-origin-when-cross-origin", body: null, method: "GET", mode: "cors", credentials: "include", }) if (!response.ok) { const text = await response.text() throw new Error(`LinkedIn API error (${response.status}): ${text}`) } return response.json() as TResponse } catch (err) { throw err }}export async function handleVoyagerRequest(request: VoyagerRequest): Promise<VoyagerResponse> { try { const browserCookies = await browser.cookies.getAll({}) const csrfToken = findLinkedInCsrfToken(browserCookies) if (!csrfToken) { throw new Error("CSRF token not found") } let data: unknown switch (request.type) { case "profile": data = await sendVoyagerRequest<VoyagerProfileResponse>( `/identity/dash/profiles?q=memberIdentity&memberIdentity=${request.slug}&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-35`, csrfToken, ) break case "company": data = await sendVoyagerRequest<VoyagerCompanyResponse>( `/organization/companies?q=universalName&universalName=${request.slug}&decorationId=com.linkedin.voyager.deco.organization.web.WebFullCompanyMain-12`, csrfToken, ) break case "identityDashProfiles": data = await sendVoyagerRequest<IVoyagerDashProfileResponse>( `/graphql?variables=(memberIdentity:${request.slug})&queryId=voyagerIdentityDashProfiles.c7452e58fa37646d09dae4920fc5b4b9`, csrfToken, ) break case "search": data = await sendVoyagerRequest<VoyagerSearchResultResponse>(`/${request.slug}`, csrfToken) break } return { voyagerResponse: { type: request.type, data, }, } } catch (error) { return { voyagerResponse: { type: request.type, data: null, error: error instanceof Error ? error.message : "Unknown error", }, } }}browser.runtime.onMessage.addListener(async (msg: FromContentScriptRuntimeMessages, sender: Runtime.MessageSender) => { try { if (msg.voyagerRequest) { const response = await handleVoyagerRequest(msg.voyagerRequest) if (sender.tab?.id) { await sendMessage(sender.tab.id, response) } return response } if (msg.action === "update_tab" && msg.newUrl) { await updateCurrentTabUrl(msg.newUrl) } if (msg.newTab && sender.tab) { await newTab(msg.newTab.websiteName, msg.newTab.url, sender.tab) } else if (msg.getCookies && sender.tab && msg.getCookies.websiteName !== "X") { await getCookies(msg.getCookies.websiteName, sender.tab) } else if (msg.getCookies && sender.tab && msg.getCookies.websiteName === "X") { if (!isChromium) { // We only execute the permission request in Chrome. Firefox has the required permissions to retrieve the session cookie from x.com // The permissions API is not working on Firefox (bug -> https://bugzilla.mozilla.org/show_bug.cgi?id=1392624) await getCookies(msg.getCookies.websiteName, sender.tab) return } const permissionsGranted = await browser.permissions.request({ origins: ["*://*.x.com/*"], }) if (permissionsGranted) { await getCookies(msg.getCookies.websiteName, sender.tab) } else { await sendNotification( "PhantomBuster", "You must approve new permissions for x.com to retrieve your Twitter session cookie.", ) } } else if (msg.notif) { await sendNotification(msg.notif.title || "PhantomBuster", msg.notif.message) } else if (msg.restartMe && sender.tab && sender.tab.id) { await sendMessage(sender.tab.id, { restart: true }) } else if (msg.userInfo && sender.tab?.id) { await handleUserInfoRequest(sender.tab.id, msg.userInfo) } } catch (error) { handleError(error) }})- www.linkedin.com
Receives the authenticated Voyager API request for profile, company, identity, or search data.
- api.phantombuster.com
Receives saved lead data in the related side-panel workflow when the user saves a lead.