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
75Risk
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

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields assembled for each profile upload
FieldValueWhy it matters
Profile identity
Alex Morgan, Head of RevenueThis 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 StatesThis adds city, state, and country context to the profile record.
Current and past roles
VP Sales at ExampleCo, Jan 2022 to PresentThis 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.
03EvidenceNETWORK CAPTURE
Captured request
POSThttps://www.marketbetter.ai/api/private/plays/initiate-chrome-extension
Observed during dynamic analysis when the Sales Navigator scrape workflow was triggered.
04EvidenceCODE COMPARE
The code that does this

Sales Navigator profile collection and upload path

What it actually does
Build the LinkedIn Sales Navigator profile-detail URLscrapeSalesNavLeads.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 fieldsscrapeSalesNavLeads.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 profileDatabackground.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 MarketBetterbackground.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
    }
  }
}
05EvidenceTHIRD PARTY LIST
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.

SeverityHIGH
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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

01EvidenceCAUSE EFFECT
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.

02EvidenceFIELD TABLE
Fields assembled for the GTM task request
FieldValueWhy it matters
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, ExampleCoThis identifies the person attached to the GTM task.
Task options and note
includeNote: true; sendAsSequence: false; note: Follow up after conferenceThis records whether a note or sequence was selected and can include the note text you entered.
03EvidenceNETWORK CAPTURE
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.
04EvidenceCODE COMPARE
The code that does this

LinkedIn task payload construction and submission path

What it actually does
Build the Add to Tasks request bodycontentScript.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 MarketBetterbackground.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
    }
  }
}
05EvidenceTHIRD PARTY LIST
Remote host receiving the task request
  • www.marketbetter.ai

    Receives the LinkedIn URL, profileData, task options, note, and rawPageContent for task creation.

SeverityMEDIUM
ClassUNWANTED
TypeUnexpected
CWECWE-200
SourceAI SANDBOX

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.

01EvidenceCAUSE EFFECT
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.

02EvidenceNETWORK CAPTURE
Captured request
POSTwww.linkedin.com/voyager/api/messaging/conversations
03EvidenceFIELD TABLE
Fields in the LinkedIn Voyager requests
FieldValueWhy it matters
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.
04EvidencePLAIN NOTE
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.

Updated 30 September 2026beokggobfdlimlihpkfmfpaedkhjpbhh