Is marketbetter - AI-powered LinkedIn workflows safe?

High risk

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

talk2sunderv1.7.7Chrome Web Store
78Risk
Who publishes it

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.

Publisher
talk2sunder

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

You start the Sales Navigator scrape-list workflow from the extension sidebar.

The extension did this

The extension reads Sales Navigator lead details and sends each normalized profile to MarketBetter.

Fields assembled for each profile upload
  • Profile identity
    Alex Morgan, Head of Revenue

    This identifies the LinkedIn person being added to the MarketBetter workflow.

  • LinkedIn profile URL
    https://www.linkedin.com/in/alex-morgan-sales/

    This ties the record to a specific LinkedIn profile page.

  • Location
    San Francisco, CA, United States

    This adds city, state, and country context to the profile record.

  • Current and past roles
    VP Sales at ExampleCo, Jan 2022 to Present

    This describes the person’s job history, including titles, companies, dates, and descriptions.

  • Sales Navigator contact fields
    contactInfo: { websites: ["https://example.com"] }

    This can include contact information returned by LinkedIn for the profile.

Captured request
POSThttps://www.marketbetter.ai/api/private/plays/initiate-chrome-extension

Observed during dynamic analysis when the Sales Navigator scrape workflow was triggered.

The code that does this

Sales Navigator profile collection and upload path

Readable version

Build the LinkedIn Sales Navigator profile-detail URL

scrapeSalesNavLeads.js
function 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.js
function 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.js
if ("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.js
async 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    }  }}
Remote hosts involved in this workflow
    • 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
What actually happens
You did this

You click Add to Tasks while viewing LinkedIn content.

The extension did this

The extension reads visible page text, combines it with profile fields and the page URL, and sends the task payload to MarketBetter.

Fields assembled for the GTM task request
  • Visible LinkedIn page text
    Alex 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 URL
    https://www.linkedin.com/in/alex-morgan-sales/

    This identifies the exact LinkedIn page where the task was created.

  • Profile fields
    Alex Morgan, Head of Revenue, ExampleCo

    This identifies the person attached to the GTM task.

  • Task options and note
    includeNote: 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.

Captured request
POSThttps://www.marketbetter.ai/api/private/gtm-tasks/create-from-extension

Observed during dynamic analysis when an Add to Tasks action was submitted.

The code that does this

LinkedIn task payload construction and submission path

Readable version

Build the Add to Tasks request body

contentScript.js
const 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.js
async 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    }  }}
Remote host receiving the task request
    • 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
What actually happens
You did this

A user action starts the LinkedIn workflow.

The extension did this

The extension calls LinkedIn Voyager APIs for messaging conversations, profile search, blended search, and the current user's account URN.

Captured request
POSTwww.linkedin.com/voyager/api/messaging/conversations
Fields in the LinkedIn Voyager requests
  • Your LinkedIn browsing context
    linkedin.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 content
    Jane 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.

Observation

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

Updated 30 September 2026beokggobfdlimlihpkfmfpaedkhjpbhh