Is PhantomBuster safe?

Medium risk

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.…

PhantomBusterv1.3.10Chrome Web Store
49Risk
Who publishes it

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.

Publisher
PhantomBuster
Declared legal entity
Phantombuster
Registered address
49 Rue de Ponthieu, Paris 75008, FR
Registered contact
Guillaume Boiret

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.

phantombuster.com
Also called by 2 other listings, including PhantomBuster

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
What actually happens
You did this

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 did this

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.

Fields sent in lead-storage and contact-discovery flows
  • Name
    Jane Doe (illustrative)

    Identifies the LinkedIn profile being saved or researched.

  • LinkedIn profile URL
    https://www.linkedin.com/in/jane-doe-123456/

    Links the saved lead or contact lookup to a specific LinkedIn profile.

  • Company and role context
    Account Executive at Example Corp (illustrative)

    Describes where the person works and the role shown on the profile.

  • Contact-discovery inputs
    first_name=Jane, last_name=Doe, company=Example Corp (illustrative)

    Supplies the name, company, LinkedIn URL, and company LinkedIn URL used for contact lookup.

  • PhantomBuster organization
    org_123456 (illustrative)

    Associates the request with the PhantomBuster workspace selected in the browser.

Captured request
POSThttps://api.phantombuster.com/api/v2/org-storage/leads/save

Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a lead-save POST.

Headers
X-Phantombuster-Org
org_123456 (illustrative)
x-phantombuster-browser-extension
Chrome
x-phantombuster-browser-extension-version
1.3.9
Captured request
POSThttps://api.phantombuster.com/api/v1/discover-email

Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a discover-email POST.

Headers
Content-Type
application/json
X-Phantombuster-Org-Name
Example Workspace (illustrative)
x-phantombuster-browser-extension
Chrome
x-phantombuster-browser-extension-version
1.3.9
The code that does this

Profile fields mapped into PhantomBuster API requests

Readable version

Source-map lead-save path

src/sidepanel/services/pbV2.ts, lib/leads.ts, hooks/useCreateLeadFromCurrentTab.ts
export 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.tsx
export 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,	}}
External API destination
    • 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
What actually happens
You did this

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 did this

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.

Fields used or returned by the LinkedIn request path
  • LinkedIn session-derived token
    ajax:1234567890123456789 (illustrative)

    Lets the request use your logged-in LinkedIn browser session for the profile-data request.

  • Profile identifier
    jane-doe-123456

    Identifies which LinkedIn member profile the side panel is requesting.

  • Profile and company data
    Jane 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.

Captured request
GEThttps://www.linkedin.com/voyager/api/identity/dash/profiles?q=memberIdentity&memberIdentity=jane-doe-123456&decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-35

Code-derived endpoint and method; unauthenticated dynamic analysis did not observe a live Voyager request.

Headers
csrf-token
ajax:1234567890123456789 (illustrative)
x-restli-protocol-version
2.0.0
The code that does this

Cookie extraction and Voyager request handling in the shipped background bundle

Readable version

Source-map TypeScript for the same path

src/shared/voyager.ts and src/background/background.ts
export 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)	}})
Hosts involved in this flow
    • 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.

Updated 30 September 2026mdlnjfcpdiaclglfbdkbleiamdafilil