Is Humanlinker safe?

High risk

Humanlinker is high risk. When Humanlinker sends collected LinkedIn data to its GraphQL API, it encrypts the payload with an AES passphrase embedded in the JS bundle. A proof-of-concept confirmed the passphrase decrypts the payloads, hiding data from outsiders only.

Humanlinkerv0.16.1Chrome Web Store
72Risk

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

Publishers can request a review.

Findings

Hardcoded AES key protects LinkedIn payloads

When Humanlinker sends collected LinkedIn data to its GraphQL API, it encrypts the payload with an AES passphrase embedded in the JS bundle.

A proof-of-concept confirmed the passphrase decrypts the payloads, hiding data from outsiders only.

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

You browse LinkedIn while Humanlinker processes a profile or company page.

The foreground GraphQL helper sends the collected payload to the background layer with the action graphql-insert.

The extension did this

The extension encrypts the collected LinkedIn payload with an embedded passphrase and posts it to Humanlinker.

The passphrase is present in the shipped JavaScript, and a static proof-of-concept confirmed it works for encrypting and decrypting extension payloads.

Concrete fields in the encrypted GraphQL path
  • LinkedIn payload object
    {"linkedinId":"nelsonlevert","version":"0.16.1"}

    This is the page-derived profile or company data that Humanlinker prepares before encryption.

  • Extension version
    0.16.1

    The extension adds its own version to the payload before encrypting it.

  • Encrypted GraphQL variable
    variables.encodedLinkedinPayload.payload

    The readable LinkedIn payload is wrapped inside a single encrypted field before upload.

  • AES passphrase
    chr0m33x73ns1On($encodedLink3d1n

    The same passphrase is available to anyone who inspects the extension package, letting them read payloads the extension generates.

Captured request
POSThttps://api.humanlinker.com/graphql
Headers
Accept
application/json, application/xml, text/plain, text/html, */*
Content-Type
application/json; charset=utf-8
Authorization
Bearer <redacted>
The code that does this

The shipped background bundle encrypts the payload and posts it to GraphQL

Readable version

Formatted POST helper

assets/chunk-0ef51b91.js
static async post(d, e = !0) {  var s, C, A;  try {    const {      accessToken: D    } = await c.getStorageData(c.getChrome(), ["accessToken"]);    if (!D) throw "GraphQL post not executed. User not logged";    const S = await fetch("https://api.humanlinker.com/graphql", {        method: "POST",        credentials: "include",        headers: {          Authorization: `Bearer ${D}`,          Accept: "application/json, application/xml, text/plain, text/html, */*",          "Content-Type": "application/json; charset=utf-8"        },        body: JSON.stringify(d)      }),      a = ((s = S.headers.get("content-type")) == null ? void 0 : s.includes("application/json")) && await S.json();    if (!S.ok) throw a && a.message || S.status;    if (a.errors) {      const _ = (A = (C = a.errors) == null ? void 0 : C[0]) == null ? void 0 : A.message,        L = Object.values(re);      if (!!_ && L.includes(_)) return {        data: {          error: _        }      };      if (e) return this.onError(a.errors, d);      throw `GraphQL post failed (even after refresh token): ${JSON.stringify(a.errors)}`    } else return T.log("[BG Layer]", "Graphql response succeed (1)", a.data), {      data: a.data    }  } catch (D) {    return T.error("[BG Layer]", "GraphQL post failed.", D, d), {}  }}

Formatted insert method with passphrase use

assets/chunk-0ef51b91.js
static async insert(d, e) {  const s = `      mutation insertCompanyOrProfileFromLinkedinChromeExtension($encodedLinkedinPayload: EncodedLinkedinAddChromePayload!) {        insertCompanyOrProfileFromLinkedinChromeExtension(encodedLinkedinPayload: $encodedLinkedinPayload) {          companyId          companyTerritoryId          contactId          profileId          scrapProfileAgain          scrapCompanyAgain          isCompanyNew          isProfileNew        }      }    `,    C = c.getChrome().runtime.getManifest();  e.version = C.version;  const A = {    payload: ne.encrypt(JSON.stringify(e), "chr0m33x73ns1On($encodedLink3d1n").toString()  };  d(await this.post({    query: s,    variables: {      encodedLinkedinPayload: A    }  }))}
Reproduce it yourself

Recreates the CryptoJS passphrase format used by the extension so an encoded payload can be decrypted with the embedded passphrase.

Requires
  • Node.js 18+
humanlinker-aes-reproducer.js · js
const crypto = require('crypto');const PASSPHRASE = 'chr0m33x73ns1On($encodedLink3d1n';function evpBytesToKey(password, salt, keyBytes, ivBytes) {  let out = Buffer.alloc(0);  let block = Buffer.alloc(0);  while (out.length < keyBytes + ivBytes) {    const md5 = crypto.createHash('md5');    md5.update(block);    md5.update(Buffer.from(password, 'utf8'));    md5.update(salt);    block = md5.digest();    out = Buffer.concat([out, block]);  }  return {    key: out.subarray(0, keyBytes),    iv: out.subarray(keyBytes, keyBytes + ivBytes),  };}function decryptCryptoJsOpenSsl(base64Payload) {  const raw = Buffer.from(base64Payload, 'base64');  if (raw.subarray(0, 8).toString('utf8') !== 'Salted__') {    throw new Error('Payload is not in CryptoJS/OpenSSL salted format');  }  const salt = raw.subarray(8, 16);  const encrypted = raw.subarray(16);  const { key, iv } = evpBytesToKey(PASSPHRASE, salt, 32, 16);  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);  return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');}function encryptCryptoJsOpenSsl(plaintext) {  const salt = crypto.randomBytes(8);  const { key, iv } = evpBytesToKey(PASSPHRASE, salt, 32, 16);  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);  return Buffer.concat([Buffer.from('Salted__', 'utf8'), salt, encrypted]).toString('base64');}const input = process.argv[2];if (input) {  process.stdout.write(decryptCryptoJsOpenSsl(input) + '\n');} else {  const sample = JSON.stringify({ linkedinId: 'nelsonlevert', version: '0.16.1' });  const encrypted = encryptCryptoJsOpenSsl(sample);  const decrypted = decryptCryptoJsOpenSsl(encrypted);  console.log(JSON.stringify({ encrypted, decrypted }, null, 2));}
How to run it
  1. 1node humanlinker-aes-reproducer.js '<base64 payload>' or node humanlinker-aes-reproducer.js to run a local round trip
Host that receives the encrypted LinkedIn payload
    • api.humanlinker.com

    Receives the GraphQL POST containing variables.encodedLinkedinPayload.payload from the extension background layer.

Updated 30 September 2026hpfndbeelpjpdgeaoknoeggagglgelhp