Is marketbetter - AI-powered LinkedIn workflows safe?
marketbetter - AI-powered LinkedIn workflows is high risk. Starting the Sales Navigator list workflow, the extension uses your LinkedIn session to read lead/profile records, converts each into MarketBetter fields, and posts them to marketbetter.ai/api/private/plays/initiate-chrome-extension.…
Who publishes ittalk2sunder - no other listings under this identity
talk2sunder - no other listings under this identity
What this publisher told the store about itself, and the other listings that told it the same thing.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Sales Navigator profiles sent to MarketBetter
Starting the Sales Navigator list workflow, the extension uses your LinkedIn session to read lead/profile records, converts each into MarketBetter fields, and posts them to marketbetter.ai/api/private/plays/initiate-chrome-extension.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You start the Sales Navigator scrape-list workflow from the extension sidebar.
The extension reads Sales Navigator lead details and sends each normalized profile to MarketBetter.
- Profile identityAlex Morgan, Head of Revenue
This identifies the LinkedIn person being added to the MarketBetter workflow.
- LinkedIn profile URLhttps://www.linkedin.com/in/alex-morgan-sales/
This ties the record to a specific LinkedIn profile page.
- LocationSan Francisco, CA, United States
This adds city, state, and country context to the profile record.
- Current and past rolesVP Sales at ExampleCo, Jan 2022 to Present
This describes the person’s job history, including titles, companies, dates, and descriptions.
- Sales Navigator contact fieldscontactInfo: { websites: ["https://example.com"] }
This can include contact information returned by LinkedIn for the profile.
Observed during dynamic analysis when the Sales Navigator scrape workflow was triggered.
Sales Navigator profile collection and upload path
Build the LinkedIn Sales Navigator profile-detail URL
scrapeSalesNavLeads.jsfunction buildProfileDetailUrl(lead) { const urnMatch = lead.entityUrn?.match(/\(([^)]+)\)/); if (!urnMatch) return null; const [rawProfileId, authType, authToken] = urnMatch[1].split(","); return `https://www.linkedin.com/sales-api/salesApiProfiles/(profileId:${rawProfileId},authType:${authType},authToken:${authToken})?decoration=${encodeURIComponent("(entityUrn,objectUrn,firstName,lastName,fullName,headline,pronoun,degree,profileUnlockInfo,location,listCount,summary,savedLead,defaultPosition,contactInfo,crmStatus,pendingInvitation,unlocked,flagshipProfileUrl,fullNamePronunciationAudio,memorialized,positions*(companyName,current,new,description,endedOn,posId,startedOn,title,location,richMedia*,companyUrn~fs_salesCompany(entityUrn,name,companyPictureDisplayImage)),crmManualMatched)").replace(/\*/g,"*").replace(/~/g,"~").replace(/\(/g,"%28").replace(/\)/g,"%29")}`}Normalize returned Sales Navigator profile fields
scrapeSalesNavLeads.jsfunction transformSalesNavProfileToStandardFormat(detailData) { if (!detailData) return null; try { const locationParts = (detailData.location || "").split(", "), location = { city: locationParts[0] || "", state: locationParts[1] || "", country: locationParts[2] || locationParts[1] || "" }, currentPosition = detailData.defaultPosition || detailData.positions?.[0] || {}, currentRole = { title: currentPosition.title || "", company: currentPosition.companyName || "", duration: { start_date: formatLinkedInDate(currentPosition.startedOn), end_date: currentPosition.current ? "Present" : formatLinkedInDate(currentPosition.endedOn), total_duration: "" }, location: { city: currentPosition.location ? currentPosition.location.split(", ")[0] || "" : location.city, state: currentPosition.location ? currentPosition.location.split(", ")[1] || "" : location.state, country: currentPosition.location ? currentPosition.location.split(", ")[2] || "" : location.country }, description: currentPosition.description || "" }, previousCompanies = (detailData.positions || []).filter(pos => !pos.current).map(pos => pos.companyName).filter(Boolean), experience = (detailData.positions || []).map(pos => ({ title: pos.title || "", company: pos.companyName || "", company_urn: pos["companyUrn~fs_salesCompany"]?.entityUrn || null, position_id: pos.posId || null, recently_added: pos.new || !1, duration: { start_date: formatLinkedInDate(pos.startedOn), end_date: pos.current ? "Present" : formatLinkedInDate(pos.endedOn), total_duration: "" }, location: { city: pos.location && pos.location.split(", ")[0] || "", state: pos.location && pos.location.split(", ")[1] || "", country: pos.location && pos.location.split(", ")[2] || "" }, description: pos.description || "" })); return { profile: { name: detailData.fullName || `${detailData.firstName||""} ${detailData.lastName||""}`.trim(), headline: detailData.headline || "", profile_picture_url: "", profile_url: normalizeLinkedInUrl(detailData.flagshipProfileUrl || ""), location: location, member_urn: extractProfileIdFromEntityUrn(detailData.entityUrn) || detailData.objectUrn || null, entity_urn: detailData.entityUrn || null, contact_info: detailData.contactInfo || {}, pronoun: detailData.pronoun || null, connections: "", degree_connection: formatDegree(detailData.degree), unlocked: detailData.unlocked || !1, pending_invitation: detailData.pendingInvitation || !1, saved_lead: detailData.savedLead || !1, memorialized: detailData.memorialized || !1, premium_status: !1, ...detailData._fromFallback ? { _partial: !0 } : {} }, current_role: currentRole, previous_companies: previousCompanies, about: { summary: detailData.summary || "" }, experience: experience, education: [], skills: [], languages: [] } } catch (error) { return null }}Handle SCRAPE_SALESNAV_DETAILED and create profileData
background.jsif ("SCRAPE_SALESNAV_DETAILED" === request.action) return (async () => { const scrapeSessionId = startScrapeSession(); let workingAudienceId = request.workflowData?.audienceId, profilesSent = 0, profilesSkipped = 0, profilesInserted = 0, profilesFailed = 0; const listName = request.workflowData?.listName || "", resumeCheck = await checkExistingAudienceMembers(listName), existingMemberUrns = new Set(resumeCheck.existingMemberUrns || []); resumeCheck.audienceId && !workingAudienceId && (workingAudienceId = resumeCheck.audienceId), existingMemberUrns.size, startScrapeKeepalive(), scrapeSalesNavLeads({ getLatestCaptures: () => ({ url: latestCapturedPeopleSearchUrl, headers: latestCapturedSalesApiHeaders }), tabId: request.tabId ?? sender?.tab?.id, startPage: request.startPage || 1, endPage: request.endPage || null, updatePopupSyncState: updatePopupSyncState, shouldSkipLead: existingMemberUrns.size > 0 ? lead => lead.objectUrn && existingMemberUrns.has(lead.objectUrn) : null, onProfileScraped: async lead => { if (!lead || !lead.detail) return; const standardProfile = transformSalesNavProfileToStandardFormat(lead.detail); if (standardProfile && request.workflowData) { updatePopupSyncState(`UPLOADING_PROFILE (${profilesSkipped+profilesSent+1})`); const requestBody = { ...request.workflowData, audienceId: workingAudienceId, category: "sales_navigator", profileData: [standardProfile] }; try { const postResult = await postProfileWithRetry(requestBody, { attemptLabel: `${profilesSkipped+profilesSent+profilesFailed+1}`, updatePopupSyncState: updatePopupSyncState }); if (!postResult.success) { profilesFailed++; const failureMessage = describeProfilePostFailure(postResult), error = new Error(isMarketBetterAuthFailure(postResult) ? failureMessage : `Profile upload failed after retry: ${failureMessage}`); throw error.code = postResult.code, error.status = postResult.status, error } postResult.data?.audienceId && (workingAudienceId = postResult.data.audienceId), null != postResult.data?.insertedCount && (profilesInserted += postResult.data.insertedCount), profilesSent++ } catch (postErr) { throw postErr } } }, onProfileSkipped: (lead, index, total) => { profilesSkipped++, updatePopupSyncState(`SKIPPED_PROFILE ${index+1}/${total}`) } }).then(async rawData => { if (stopScrapeKeepalive(), !isScrapeSessionActive(scrapeSessionId)) return; const withDetail = rawData.filter(lead => lead.detail), duplicateCount = (withDetail.filter(lead => !lead.detail._fromFallback), withDetail.filter(lead => lead.detail._fromFallback), Math.max(profilesSent - profilesInserted, 0)), success = 0 === profilesFailed && (profilesSent > 0 || profilesSkipped > 0); let statusParts = profilesInserted > 0 ? [`${profilesInserted} new profiles added`] : ["no new profiles added"]; duplicateCount > 0 && statusParts.push(`${duplicateCount} duplicates skipped`), profilesSkipped > 0 && statusParts.push(`${profilesSkipped} already existed`), profilesFailed > 0 && statusParts.push(`${profilesFailed} failed`); let postResult = { success: success, message: success ? `Scrape complete: ${statusParts.join(", ")}` : `Sales Navigator scrape failed: ${statusParts.join(", ")}. Please retry.` }; request.workflowData || (postResult = { success: !1, message: "No workflow data provided" }), chrome.storage.local.set({ lastScrapeResult: { success: postResult.success, profileCount: profilesInserted, failedCount: profilesFailed, message: postResult.message, timestamp: Date.now() } }, () => { updatePopupSyncState("NONE") }); const result = { success: postResult.success, profileCount: profilesInserted, failedCount: profilesFailed, message: postResult.message }; popupPort && popupPort.postMessage({ type: "scrapeResult", ...result }), sendResponse(result) }).catch(err => { if (stopScrapeKeepalive(), !isScrapeSessionActive(scrapeSessionId)) return; const aborted = err.message && err.message.includes("stopped by user"), result = aborted ? { success: !1, aborted: !0, message: "Scraping stopped." } : { success: !1, failedCount: profilesFailed, code: err.code || (/MarketBetter session expired|sign in to MarketBetter/i.test(err.message || "") ? "MARKETBETTER_AUTH_REQUIRED" : void 0), status: err.status, message: err.message || "Detailed scraping failed. Please try again." }, finalizeSyncState = () => updatePopupSyncState("NONE"); aborted ? chrome.storage.local.remove(["lastScrapeResult", "activeScrapeContext"], finalizeSyncState) : chrome.storage.local.set({ lastScrapeResult: { success: !1, failedCount: profilesFailed, code: result.code, status: result.status, message: result.message, timestamp: Date.now() } }, finalizeSyncState), popupPort && popupPort.postMessage({ type: "scrapeResult", ...result }), sendResponse(result) }) })(), !0;POST the workflow payload to MarketBetter
background.jsasync function triggerWorkflowAPI(requestData) { try { const data = await chrome.storage.local.get("authToken"); if (!data.authToken) { const error = new Error("Please sign in to MarketBetter and retry."); throw error.code = "MARKETBETTER_AUTH_REQUIRED", error } const response = await fetch(`${API_URL}/private/plays/initiate-chrome-extension`, { method: "POST", headers: { Authorization: `Bearer ${data.authToken}`, "Content-Type": "application/json" }, body: JSON.stringify(requestData) }); if (401 === response.status || 403 === response.status) { await chrome.storage.local.remove("authToken"); const error = new Error("MarketBetter session expired. Please sign in again and retry."); throw error.status = response.status, error.code = "MARKETBETTER_AUTH_REQUIRED", error } if (!response.ok) { let errorMessage = `Failed to trigger workflow (${response.status})`; try { const errorData = await response.json(); errorData.message && (errorMessage = errorData.message) } catch (e) {} const error = new Error(errorMessage); throw error.status = response.status, error } const result = await response.json(); if (!1 === result?.success) { const resultStatus = result.status || result.statusCode || response.status, resultCode = result.code || (401 === resultStatus || 403 === resultStatus ? "MARKETBETTER_AUTH_REQUIRED" : void 0); return "MARKETBETTER_AUTH_REQUIRED" === resultCode && await chrome.storage.local.remove("authToken"), { success: !1, data: result, status: resultStatus, code: resultCode, message: result.message || "MarketBetter rejected the profile upload." } } return { success: !0, data: result, status: response.status } } catch (error) { return { success: !1, message: error.message, status: error.status, code: error.code } }}- www.linkedin.com
Source of the Sales Navigator list and profile API responses read with the signed-in LinkedIn session.
- www.marketbetter.ai
Receives the normalized profileData array through the Chrome-extension workflow endpoint.
LinkedIn page text sent when adding GTM tasks
Adding a GTM task from a LinkedIn page, the extension clones the page body, strips non-content elements, keeps up to 50KB text, and sends it with the URL and profile fields to marketbetter.ai/api/private/gtm-tasks/create-from-extension
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You click Add to Tasks while viewing LinkedIn content.
The extension reads visible page text, combines it with profile fields and the page URL, and sends the task payload to MarketBetter.
- Visible LinkedIn page textAlex Morgan posted about expanding the revenue team this quarter. (illustrative)
This can include text visible on the LinkedIn page at the time you create the task.
- LinkedIn page URLhttps://www.linkedin.com/in/alex-morgan-sales/
This identifies the exact LinkedIn page where the task was created.
- Profile fieldsAlex Morgan, Head of Revenue, ExampleCo
This identifies the person attached to the GTM task.
- Task options and noteincludeNote: true; sendAsSequence: false; note: Follow up after conference
This records whether a note or sequence was selected and can include the note text you entered.
Observed during dynamic analysis when an Add to Tasks action was submitted.
LinkedIn task payload construction and submission path
Build the Add to Tasks request body
contentScript.jsconst submitBtn = dropdown.querySelector("#addToTasksBtn"); submitBtn && submitBtn.addEventListener("click", async () => { const note = noteInput?.value?.trim() || "", includeNote = "true" === includeNoteCheckbox?.dataset.checked, sendAsSequence = "true" === sendAsSequenceCheckbox?.dataset.checked; submitBtn.disabled = !0, submitBtn.style.opacity = "0.6", submitBtn.textContent = "Adding..."; try { const nameParts = (profileInfo.name || "").trim().split(/\s+/), firstName = nameParts[0] || "", lastName = nameParts.slice(1).join(" ") || "", rawPageContent = extractPageContentAsText(); let linkedinUrl = profileInfo.profileUrl || window.location.href.split("?")[0]; linkedinUrl.endsWith("/") || (linkedinUrl += "/"); const requestBody = { linkedinUrl: linkedinUrl, profileData: { name: profileInfo.name || "", firstName: firstName, lastName: lastName, title: profileInfo.headline || "", company: profileInfo.company || "", linkedinUrl: linkedinUrl }, note: includeNote ? note : "", includeNote: includeNote, sendAsSequence: sendAsSequence, rawPageContent: rawPageContent }, response = await chrome.runtime.sendMessage({ action: "createGtmTask", data: requestBody }); if (!response || !response.success) throw new Error(response?.message || "Failed to add task"); submitBtn.style.opacity = "1", submitBtn.style.background = "#2e7d32", submitBtn.textContent = "Added to Tasks!", setTimeout(() => { dropdown._cleanup?.(), dropdown.remove() }, 1500) } catch (err) { submitBtn.disabled = !1, submitBtn.style.opacity = "1", submitBtn.style.background = "#c62828", submitBtn.textContent = err.message || "Failed", setTimeout(() => { submitBtn.style.background = "#094ffe", submitBtn.textContent = "Add to Tasks" }, 2e3) } });POST the GTM task payload to MarketBetter
background.jsasync function createGtmTaskAPI(requestData) { try { const data = await chrome.storage.local.get("authToken"); if (!data.authToken) throw new Error("Please log in to MarketBetter extension"); const response = await fetch(`${API_URL}/private/gtm-tasks/create-from-extension`, { method: "POST", headers: { Authorization: `Bearer ${data.authToken}`, "Content-Type": "application/json" }, body: JSON.stringify(requestData) }); if (401 === response.status) throw await chrome.storage.local.remove("authToken"), new Error("Session expired. Please log in again."); if (!response.ok) { let errorMessage = `Failed to add task (${response.status})`; try { const errorData = await response.json(); errorData.message && (errorMessage = errorData.message) } catch (e) {} throw new Error(errorMessage) } return { success: !0, data: await response.json() } } catch (error) { return { success: !1, message: error.message } }}- www.marketbetter.ai
Receives the LinkedIn URL, profileData, task options, note, and rawPageContent for task creation.
LinkedIn conversation and search data requested through Voyager APIs
When you use marketbetter's LinkedIn workflow, it can call LinkedIn Voyager APIs for conversations, profile search, and your account URN, using your session to return data beyond the current page.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
A user action starts the LinkedIn workflow.
The extension calls LinkedIn Voyager APIs for messaging conversations, profile search, blended search, and the current user's account URN.
- Your LinkedIn browsing contextlinkedin.com/search/results/people/?keywords=example (illustrative)
Shows which LinkedIn conversation or profile-search context the workflow is requesting through LinkedIn's APIs.
- LinkedIn page and profile contentJane Doe, jane-doe, urn:li:fs_miniProfile:12345, Product Lead (illustrative)
Can include conversation participant fields and profile search result details such as names, public identifiers, entity URNs, and headlines.
Observed during dynamic analysis: runtime messages route to functions that fetch LinkedIn conversation participants and search profiles by name, and those functions use LinkedIn session CSRF context for Voyager API requests.
+3 more findings not shown