Is Surfe safe?
Surfe is high risk. On a Surfe CRM update flow on LinkedIn, the extension can request Voyager profile data via the active session and PUT mapped fields to https://api.prod.surfe.com/auth/contacts. Testing lacked a token and session, but the code path exists.…
Who publishes itGROWSTER - no other listings under this identity
GROWSTER - 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
LinkedIn profile data syncs to Surfe CRM
On a Surfe CRM update flow on LinkedIn, the extension can request Voyager profile data via the active session and PUT mapped fields to https://api.prod.surfe.com/auth/contacts.
Testing lacked a token and session, but the code path exists.
You use a Surfe CRM update flow while viewing LinkedIn.
The extension can read LinkedIn profile data with your active session and send mapped contact fields to Surfe.
| Field | Value | Why it matters | |
|---|---|---|---|
LinkedIn identity | Jane Doe, https://www.linkedin.com/in/jane-doe/ | Identifies the LinkedIn member being synced into the CRM record. | |
Profile text | headline: Account Executive; summary: Helps enterprise teams evaluate CRM tooling | Adds biographical and job context from the LinkedIn profile to the CRM record. | |
Location and language | Paris, Ile-de-France, FR; language: en | Adds where the LinkedIn member is based and the locale LinkedIn returned for the profile. | |
Company details | Surfe, linkedinID: 1234567, industry: Software Development | Links the person to company records and company LinkedIn identifiers in the CRM. | |
CRM-side cookies | hubspotapi-csrf=abc123; hubspotapi=session-value | Can include CRM cookies that the extension attaches when updating the contact record. |
The CRM update path reads LinkedIn profile data and PUTs it to Surfe
be = "https://api.prod.surfe.com",
xe = "https://app.surfe.com",
Ce = "https://account.surfe.com",
Ee = "pub4cc3fa1c9969c63aabeda33f780bd9ee",
Se = "datadoghq.eu",
ke = parseInt("MISSING_ENV_VAR".DATADOG_RUM_SAMPLE_RATE ?? ""),
Ne = parseInt("MISSING_ENV_VAR".DATADOG_RUM_SAMPLE_RATE ?? ""),
je = "https://intercom.help/surfe/en/",
Re = "linkedin.com",
Te = "production", Wf = (e, t, n = {}, r) => {
const {
url: a,
body: o,
token: i
} = (e => {
try {
const {
url: t,
params: n,
body: r,
token: a = ""
} = e, o = null !== r ? JSON.stringify(r) : null, i = new URLSearchParams(n);
Array.from(i.entries()).forEach(([e, t]) => {
"undefined" === t && i.set(e, "")
});
const s = new URL(t, e.baseUrl ?? be);
return s.search = i.toString(), {
body: o,
url: s.toString(),
token: a
}
} catch (e) {
return yv("parse-request", e), {
url: "",
token: "",
body: ""
}
}
})(t);
return Promise.resolve().then(async () => {
const t = await (async e => {
if (e.includes("feature-flags")) return {};
try {
const {
isSidePanelEnabled: e = "false"
} = await ww(["isSidePanelEnabled"]);
return {
"X-Extension-Type": "true" === e ? "sidepanel" : "injected"
}
} catch (e) {
return gv("Failed to resolve X-Extension-Type header", {
error: e
}), {}
}
})(a);
return fetch(a, {
method: e,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Extension-Version": nv(),
Authorization: `Bearer ${i}`,
...t,
...n
},
body: o,
signal: r?.signal
})
}).then(async e => {
const {
headers: n
} = e;
return n.get("deprecated") && gv(`${t.url} was deprecated in ${n.get("expires")??""}. Please update Surfe to the newest version.\nYou can refer to https://docs.surfe.dev/ for more information, or https://intercom.help/surfe/en/ if you need some help.`), 204 === e.status || "0" === e.headers.get("content-length") ? {
data: {},
status: e.status
} : (async (e, t) => {
try {
const n = await e.text();
if (!n && e.ok) return {
data: {},
status: e.status
};
const r = n ? JSON.parse(n) : null;
return e.status >= 300 && qf(r, e.status, e.headers, t), {
data: r,
status: e.status
}
} catch (t) {
if (t instanceof Zf) throw t;
if (e.ok) return {
data: {},
status: e.status
};
throw t
}
})(e, r)
})
},
Gf = async (e, t, n, r) => {
const {
token: a
} = await ww(["token"]);
if (!a) return gv("API token not found", {
endpoint: t.url
}), {
data: {},
status: 401
};
try {
return await Wf(e, {
...t,
token: a
}, n, r)
} catch (e) {
if (e instanceof Zf && (((e, t) => {
const n = e;
if ("object" == typeof e && xw(n.message)) {
const t = JSON.parse(n.message ?? "{}"),
r = "invalid_grant" === t.error && "expired access/refresh token" === t.error_description;
return r && yv("invalid-grant-error", e), r
}
return !(401 !== e.data.code && 403 !== e.data.code || !t || !Le.includes(t))
})(e) || 14 === e.data.errorCode)) return wf(), Promise.reject();
throw e
}
}, Kf = (() => { async function AM(e, t, n, r = {
retry: !0
}) {
const {
sessionID: a
} = await ww();
if (!a) throw new Error("No session ID");
const o = await async function(e) {
let t = await OM();
if (t.endpoints || (t = await OM()), !t.endpoints) return [];
let n = t.endpoints[e];
if (n && 0 !== n.length || (t = await OM()), !t.endpoints) return [];
if (n = t.endpoints[e], !n || 0 === n.length) throw new Error(`Invalid Endpoint ${e}`);
return n.sort((e, t) => e.date && t.date ? new Date(t.date).getTime() - new Date(e.date).getTime() : 0), n.map(e => e.endpoint)
}(e);
for (const i of o) try {
const o = i.replace(/\%\%.*?\%\%/g, e => {
if (!t) throw Error("No arguments given for dynamic part of url");
if (!t[e.replace(/\%/g, "")]) throw Error("Wrong arguments given for dynamic part of url");
return t[e.replace(/\%/g, "")]
}),
s = `https://www.${Re}${o}${n??""}`,
l = await fetch(s, {
method: "GET",
headers: {
accept: "application/vnd.linkedin.normalized+json+2.1",
"csrf-token": a,
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"x-li-lang": "en_US",
"x-restli-protocol-version": "2.0.0"
},
referrerPolicy: "no-referrer-when-downgrade",
mode: "cors",
credentials: "include"
});
if (200 !== l.status) {
const e = await l.text();
yv("failedLIRequest", {
message: `linked-in-request url=${s} res=${l.status}`,
errorDetails: {
body: e,
returnStatus: l.status,
id: "failedLIRequest"
}
})
}
if (403 === l.status && r.retry) {
if (await MM()) return chrome.runtime.sendMessage({
msg: "refresh-li-cookies"
}), await fw("sessionID"), await gw({
lastSessionIDUpdate: (new Date).toISOString()
}), vv("linkedin-endpoint-query", {
msg: "refresh-session-cookies"
}), AM(e, t, n, {
retry: !1
})
}
if (200 !== l.status || "error" === l.type) {
const {
statusText: e,
status: t
} = l;
yv("linkedin-endpoint-query", Hf({
statusCode: t,
statusText: e
}));
continue
}
return await l.json()
} catch (e) {
yv("linkedin-endpoint-query", Hf(e))
}
return null
} jA = async e => {
if (NA.profile && e === NA.profileUrl && Date.now() - NA.fetchedAt < 3e5) return NA.profile;
const t = !!e;
e || (e = window.location.href);
const n = Kw(e),
r = await AM("contactProfile", {
id: n
});
if (!r?.included || 0 === r.included.length) throw Error("Could not fetch contact profile from LinkedIn...");
let a = "";
const o = [],
i = [],
s = [],
l = new iA;
for (const e of r.included)
if (pA(e)) {
l.linkedinUrl = `https://www.linkedin.com/in/${e.publicIdentifier??""}/`, l.entityURN = e.entityUrn, l.publicIdentifier = e.publicIdentifier, l.objectUrn = e.objectUrn, l.firstName = e.firstName, l.lastName = e.lastName, l.address = e.address, l.countryCode = e.location?.countryCode, l.language = e.primaryLocale?.language, l.headline = e.headline, l.summary = e.summary, l.premium = e.premium, l.creator = e.creator, l.hasSalesNav = !!e.premium, l.premium = e.premium, l.creator = e.creator;
const n = e.birthDateOn?.day,
r = e.birthDateOn?.month;
if (l.birthDate = n && r ? `${n}-${r}` : void 0, e.profilePicture) {
const {
displayImageWithFrameReferenceUnion: t,
displayImageReference: n
} = e.profilePicture, r = n ?? t;
if (r) {
const e = r.vectorImage.rootUrl,
t = r.vectorImage.artifacts;
for (const n of t)
if (l.pictureUrl = e + n.fileIdentifyingUrlPathSegment, l.pictureExpiryDate = new Date(n.expiresAt), 400 === n.width) break
}
}
if (a = e["*profilePositionGroups"], !t) {
const t = await zf({
isSalesNav: !1
});
if ("" !== t && t !== `${e.firstName} ${e.lastName}`) {
const e = t.split(" ");
l.firstName = e[0], e.length <= 1 ? l.lastName = "" : l.lastName = e.slice(1).join(" ")
}
}
} else if (hA(e)) l.educations.push({
schoolName: e.schoolName,
description: e.description,
fieldOfStudy: e.fieldOfStudy
});
else if (fA(e)) {
const t = new Date,
n = t.setMonth(t.getMonth() - 6),
r = e.dateRange?.start && {
...e.dateRange.start,
month: e.dateRange.start.month ? e.dateRange.start.month - 1 : e.dateRange.start.month
},
a = e.dateRange?.end && {
...e.dateRange.end,
month: e.dateRange.end.month ? e.dateRange.end.month - 1 : e.dateRange.end.month
},
{
startDate: i,
endDate: s
} = kA(r, a);
o.push({
entityUrn: e.entityUrn,
companyName: e.companyName,
companyUrn: e.companyUrn,
dateRange: e.dateRange,
startDate_: i,
endDate_: s,
isRecent: +n <= +s
})
} else if (mA(e)) {
const {
startDate: t,
endDate: n
} = kA(e.dateRange?.start, e.dateRange?.end), r = new Date, a = r.setMonth(r.getMonth() - 6);
l.positions.push({
companyName: e.companyName,
dateRange: e.dateRange,
title: e.title,
startDate_: t,
endDate_: n,
description: e.description,
location: e.locationName,
companyUrn: e.companyUrn,
isRecent: +a <= +n
})
} else if (wA(e)) l.languages.push({
name: e.name,
proficiency: e.proficiency
});
else if (vA(e)) l.skills.push(e.name), l.completeSkills || (l.completeSkills = []), l.completeSkills.push({
name: e.name,
entityUrn: e.entityUrn
});
else if (yA(e)) s.push({
name: e.name,
linkedinUrl: e.url,
companyUrn: e.entityUrn,
universalName: e.universalName
});
else if (bA(e)) i.push({
entityUrn: e.entityUrn,
"*elements": e["*elements"]
});
else if (gA(e)) {
const {
city: t,
state: n
} = $w(e.defaultLocalizedName);
l.location = e.defaultLocalizedName, l.city = t, l.state = n
}
const c = await AM("contactProfileSupplementary", {
id: n
});
if (c?.included || 0 === c?.included?.length)
for (const e of c.included)
if (xA(e)) {
const t = e.memberRelationshipUnion?.noConnection?.memberDistance;
if (t) switch (t) {
case uA.OUT_OF_NETWORK:
l.connectionDegreeLevel = "out of network";
break;
case uA.DISTANCE_3:
l.connectionDegreeLevel = "3";
break;
case uA.DISTANCE_2:
l.connectionDegreeLevel = "2";
break;
default:
l.connectionDegreeLevel = "unknown"
} else l.connectionDegreeLevel = "1"
} else CA(e) && Number(e.createdAt) && (l.connectionDate = new Date(Number(e.createdAt)).toJSON());
let d = [];
for (const e of i) e.entityUrn === a && e["*elements"] && (d = e["*elements"]);
l.positions.sort((e, t) => {
const n = +t.endDate_ - +e.endDate_;
return 0 === n ? +t.startDate_ - +e.startDate_ : n
});
for (const e of l.positions)
for (const t of o) t.companyUrn !== e.companyUrn || t.title || (t.title = e.title);
if (l.companies = s, d.length > 0)
for (const e of d)
for (const t of o) t.entityUrn === e && l.positionGroups.push(t);
return e && (NA.fetchedAt = Date.now(), NA.profile = l, NA.profileUrl = e), l
}, RA = async (e, t, n) => { }), XA = e => {
return {
id: e.id,
firstName: e.firstName,
lastName: e.lastName,
headline: e.headline,
summary: e.summary,
location: e.location,
address: e.address,
birthDate: e.birthDate,
city: e.city,
state: e.state,
countryCode: e.countryCode,
connectionDegreeLevel: e.connectionDegreeLevel,
connectionDate: e.connectionDate,
jobTitle: e.jobTitle,
linkedinUrl: e.linkedinUrl,
profileURL: e.crmUrl,
entityURN: e.entityURN,
pictureUrl: e.pictureUrl,
birthYear: e.birthYear,
company: e.company && (t = e.company, {
id: t.id,
name: t.name,
tagline: t.tagline,
description: t.description,
foundedYear: t.foundedYear,
phone: t.phone,
companyType: t.type,
linkedinHandle: t.linkedinHandle,
linkedinID: t.linkedinID,
linkedinUrl: t.linkedinUrl,
entityUrn: t.entityUrn,
companyPageUrl: t.pageUrl,
headquarter: t.headquarter,
staffCountRange: t.staffCountRange,
extraFields: t.extraFields,
stage: t.stage,
status: t.status,
industry: t.industry,
owner: t.owner?.name,
ownerID: t.owner?.id,
ownerPictureURL: t.owner?.pictureUrl,
website: t.website,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
salesNavUrn: ""
}),
companyName: e.company?.name,
educations: e.educations,
positions: e.positions,
positionGroups: e.positionGroups,
languages: e.languages,
skills: e.skills,
email: e.email,
oldEmail: e.oldEmail,
phone: e.phone,
oldPhone: e.oldPhone,
stage: e.stage,
status: e.status,
extraFields: e.extraFields,
customFields: e.customFields,
tags: e.tags,
ownerID: e.owner?.id,
ownerName: e.owner?.name,
isLead: e.isLead,
personalWebsite: e.personalWebsite,
entitySalesNavURN: e.entitySalesNavURN,
secondPhone: e.secondPhone,
salesNavUrn: ""
};
var t
}, _A = async e => { }, eL = async (e, t, n) => {
const {
isLead: r = !1,
isRelink: a = !1,
auto: o = !1,
forceCompanyUpdate: i = !1,
cookies: s
} = n ?? {}, l = r || t.isLead, c = {
...XA(t),
salesNavURN: t.salesNavURN
}, {
data: d
} = await Gf("PUT", {
url: "/auth/contacts",
params: {
id: e,
auto: o,
lead: l,
relink: a,
forceCompanyUpdate: i
},
body: {
...c,
cookies: s
}
});
return JA(d) onSave: () => {
w(!1), g(!1), (async () => {
if (i(!0), n) {
const e = a ? await LA() : await jA();
await qA(n, {
linkedinUrl: e.linkedinUrl
})
} else if (r) {
const e = a ? await EA() : await oA();
await tA(r.id, {
linkedinUrl: e.linkedinUrl
}, {
linkedinID: XM(e.entityUrn)
})
}
i(!1)
})()
},- www.linkedin.com
LinkedIn Voyager API host queried for profile and supplementary profile data.
- api.prod.surfe.com
Surfe API host that receives the mapped CRM contact update.
Surfe hooks LinkedIn fetch when li-protect is enabled
Surfe injects a page-level script on LinkedIn pages at document start.
When li-protect cookie is true, the script replaces window.fetch with a Proxy, checks URLs for the lnokhhhekhiapce marker, rewriting matches to the extension's index.js.
You browse LinkedIn while Surfe's li-protect cookie is set to true.
The extension injects a page script that replaces window.fetch and rewrites matching request URLs.
The rewrite applies when a fetched URL contains the extension-related marker lnokhhhekhiapce.
| Field | Value | Why it matters | |
|---|---|---|---|
Activation cookie | li-protect=true | Determines whether the URL-rewrite branch is active on the LinkedIn page. | |
Matched URL marker | lnokhhhekhiapce | The script checks request URLs for this extension-related marker before rewriting them. | |
Replacement URL | chrome-extension://xxxxxxxxxxxxxxxx/index.js | Matching requests are redirected to an extension resource instead of their original URL. | |
Hook marker | window.fetchReplaced = true | Marks that the page's fetch function has been replaced. |
LinkedIn page script injection and fetch replacement
var host = location.hostname;
if (("linkedin.com" === host || host.endsWith(".linkedin.com")) && "text/html" === document.contentType) {
var s = document.createElement("script");
s.src = chrome.runtime.getURL("assets/scripts/index.js");
s.onload = function() { this.remove(); };
(document.head || document.documentElement).appendChild(s);
}function getCookie(name) {
const parts = "; " + document.cookie;
const split = parts.split("; " + name + "=");
if (split.length === 2) return split.pop().split(";").shift();
}
(function() {
const originalFetch = window.fetch;
const protectEnabled = getCookie("li-protect") === "true";
window.fetch = new Proxy(originalFetch, {
apply: function(target, thisArg, args) {
if (args.length > 0 && typeof args[0] === "string" && typeof args[0].includes === "function") {
if (protectEnabled && args[0].includes("lnokhhhekhiapce")) {
args[0] = "chrome-extension://xxxxxxxxxxxxxxxx/index.js";
}
if (args[0].includes("/sales-api/salesApiProfiles/")) {
return target.apply(thisArg, args).then(function(response) {
const clone = response.clone();
clone.json().then(function(json) {
const data = json && json.data ? json.data : json;
if (data && data.flagshipProfileUrl) {
window._surfeFlagshipProfileUrl = data.flagshipProfileUrl;
window.postMessage({ type: "surfe-salesnav-profile-resolved", flagshipProfileUrl: data.flagshipProfileUrl }, "*");
}
}).catch(function() {});
return response;
});
}
}
return target.apply(thisArg, args);
}
});
window.fetchReplaced = true;
})();Surfe loads server-controlled feature flags
We observed the extension request Surfe feature flags from https://api.prod.surfe.com/feature-flags on load.
The 8,695-byte response returned 59 flags, checked before enabling side panel, LinkedIn, template, CRM, and message features.
You load Chrome with the Surfe extension installed.
The extension requests feature flags from Surfe before you interact with a Surfe control.
Those returned flags are later checked to decide which extension features are active.
| Field | Value | Why it matters | |
|---|---|---|---|
Chrome side panel flag | CHROME_SIDEPANEL_ENABLED: true | Controls whether the Surfe side panel behavior is enabled for your browser. | |
LinkedIn injection flag | HIDE_LI_INJECTION_ENABLED: true | Controls whether a Surfe LinkedIn page integration is active in your browser. | |
Templates flag | INJECTED_TEMPLATES_ENABLED: true | Controls whether injected templates are available while you use Surfe. | |
Feature endpoint | https://api.prod.surfe.com/feature-flags | Identifies the Surfe server endpoint that supplies the configuration. |
The feature flag loader requests /feature-flags and reads flag values
hR = {
CHROME_SIDEPANEL_ENABLED: { value: true },
HIDE_LI_INJECTION_ENABLED: { value: true },
UNIFY_SIGN_IN_ENABLED: { value: false },
INJECTED_CONVERSATION_ENABLED: { value: true },
INJECTED_TEMPLATES_ENABLED: { value: true },
SIDEPANEL_MESSAGE_GENERATION_ENABLED: { value: true }
};
mR = ["feature-flags"];
fR = async () => {
const { token } = await ww(["token"]);
const { key, shouldResetCache } = ym(token);
const result = await cf.fetchQuery({
queryKey: [...mR, key],
queryFn: () => (async reset => {
const { token } = await ww(["token"]);
const request = token ? Gf : Wf;
const timestamp = reset ? (new Date).getTime() : void 0;
const { data } = await request("GET", {
url: "/feature-flags",
params: timestamp ? { timestamp } : void 0
});
return "true" === "MISSING_ENV_VAR".FF_OVERRIDE_ENABLED
? { flags: { ...data.flags, ...hR } }
: data;
})(shouldResetCache),
staleTime: 300000
});
return result;
};
wR = (featureFlags, flagName) => !!featureFlags.flags[flagName]?.value;
gR = async flagName => {
const featureFlags = await fR();
return wR(featureFlags, flagName);
};- api.prod.surfe.com
Surfe API host that returns feature flags used by the extension.
+1 more finding not shown