Is Humanlinker safe?
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.
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
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 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.
- LinkedIn payload object{"linkedinId":"nelsonlevert","version":"0.16.1"}
This is the page-derived profile or company data that Humanlinker prepares before encryption.
- Extension version0.16.1
The extension adds its own version to the payload before encrypting it.
- Encrypted GraphQL variablevariables.encodedLinkedinPayload.payload
The readable LinkedIn payload is wrapped inside a single encrypted field before upload.
- AES passphrasechr0m33x73ns1On($encodedLink3d1n
The same passphrase is available to anyone who inspects the extension package, letting them read payloads the extension generates.
- Accept
- application/json, application/xml, text/plain, text/html, */*
- Content-Type
- application/json; charset=utf-8
- Authorization
- Bearer <redacted>
The shipped background bundle encrypts the payload and posts it to GraphQL
Formatted POST helper
assets/chunk-0ef51b91.jsstatic 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.jsstatic 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 } }))}Recreates the CryptoJS passphrase format used by the extension so an encoded payload can be decrypted with the embedded passphrase.
- Node.js 18+
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));}- 1node humanlinker-aes-reproducer.js '<base64 payload>' or node humanlinker-aes-reproducer.js to run a local round trip
- api.humanlinker.com
Receives the GraphQL POST containing variables.encodedLinkedinPayload.payload from the extension background layer.