Is Kleo safe?

Medium risk

Kleo is medium risk. A FETCH_LINKEDIN_DATA message from kleo.so makes the extension read your li_at and JSESSIONID cookies for authenticated LinkedIn API calls. Path and method come from the caller. Canary cookies were confirmed relayed to Voyager.…

Kleov0.265.0Chrome Web Store
46Risk
Who publishes it

Kleo - 1 other listing from the same operator, none carrying a finding

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

Publisher
Kleo
Registered address
5 New Union Square, Apartment D112, London SW11 7BQ, GB
Registered contact
Jake Ward

Same store account

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

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

Publishers can request a review.

Findings

kleo.so Can Trigger Full LinkedIn Cookie Harvest and API Proxying

A FETCH_LINKEDIN_DATA message from kleo.so makes the extension read your li_at and JSESSIONID cookies for authenticated LinkedIn API calls.

Path and method come from the caller.

Canary cookies were confirmed relayed to Voyager.

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

The kleo.so website sends a FETCH_LINKEDIN_DATA message to the extension.

The message includes an action path, HTTP method, and optional request body, all caller-controlled.

The extension did this

The extension reads your LinkedIn session cookies and makes an authenticated LinkedIn API request on the caller's behalf.

chrome.cookies.getAll({domain: '.linkedin.com'}) returns li_at, JSESSIONID, and CSRF token; these are attached to a fetch() directed at the caller-supplied Voyager API path.

The code that does this

FETCH_LINKEDIN_DATA handler, cookie harvest and API proxy

Readable version
case 'FETCH_LINKEDIN_DATA':  return chrome.cookies.getAll({ domain: '.linkedin.com' }, async (cookies) => {    try {      // Extract CSRF token from JSESSIONID or ajax:JSESSIONID      const jsessionCookie = cookies.find(c => c.name === 'JSESSIONID');      const ajaxCookie    = cookies.find(c => c.name === 'ajax:JSESSIONID');      const csrfToken     = jsessionCookie?.value || ajaxCookie?.value;      if (!csrfToken) {        sendResponse({ success: false, error: 'LinkedIn session not found.' });        return;      }      const csrfValue = csrfToken.replace(/"/g, '');      const liAtCookie = cookies.find(c => c.name === 'li_at');      if (!liAtCookie?.value) {        sendResponse({ success: false, error: 'LinkedIn authentication required.' });        return;      }      // Action URL is fully caller-controlled      const apiUrl = message.action.startsWith('http')        ? message.action        : 'https://www.linkedin.com/voyager/api/' + message.action;      const headers = {        'Accept': 'application/vnd.linkedin.normalized+json+2.1',        'x-restli-protocol-version': '2.0.0',        'csrf-token': csrfValue,        'x-li-lang': langValue,        'x-li-page-instance': 'urn:li:page:d_flagship3_feed',        'origin': 'https://www.linkedin.com',        'Referer': 'https://www.linkedin.com/',      };      const response = await fetch(apiUrl, {        method: message.method || 'GET',        headers,        credentials: 'include',        ...(message.body ? { body: message.body } : {}),      });      // Full LinkedIn API response forwarded to sender      sendResponse({ success: true, data: await response.json() });    } catch (err) {      sendResponse({ success: false, error: err.message });    }  });
LinkedIn credentials read from browser cookie store
  • LinkedIn session token (li_at)
    AQEDATPx...XwCx_kA

    Authenticates you to LinkedIn. Any party that holds this value can make fully authenticated LinkedIn requests as you.

  • Session ID / CSRF token (JSESSIONID)
    ajax:0123456789012345678

    Used as the CSRF token header in every LinkedIn API call. Required alongside li_at to mutate LinkedIn data.

  • Ajax JSESSIONID (ajax:JSESSIONID)
    ajax:9876543210987654321

    Alternate form of the CSRF token accepted by some LinkedIn Voyager endpoints.

  • Caller-supplied API path
    /identity/profiles/ACoAAABxyzABCD/topCard?decorationId=com.linkedin.voyager.dash.deco.identity.profile.TopCard

    The API path or URL the site tells the extension to call, appended to https://www.linkedin.com/voyager/api/ unless it starts with http.

Parties involved in the cookie access and API proxy chain
    • www.linkedin.com

    LinkedIn Voyager API, destination of the authenticated fetch(), called with credentials:include and the user's real session cookies.

    • kleo.so

    Kleo web app, listed in externally_connectable, sends the FETCH_LINKEDIN_DATA command and receives the full LinkedIn API response.

    • linkedin-prod-api.kleo.so

    Kleo backend, receives LinkedIn posts and profile data via a separate UPDATE_POSTS flow through the same message bus.

Why externally_connectable does not limit the risk

The manifest restricts external message senders to `*://*.kleo.so/*` and `*://*.localhost/*`, which prevents arbitrary websites from triggering this handler. However, the handler does not validate `sender.url` or `sender.origin` inside the FETCH_LINKEDIN_DATA branch. Any page served from any kleo.so subdomain — including one that is compromised, XSS-vulnerable, or served from a dangling subdomain — can instruct the extension to read the user's LinkedIn cookies and relay authenticated requests to an arbitrary LinkedIn Voyager API path. The action URL is fully caller-controlled with no allowlist.

Supabase client uses bundled anonymous API key

Kleo initializes a Supabase client for uk.server2.data.kleo.so with a bundled anonymous API key, configured to refresh and persist sessions.

The storage adapter keeps kleo_auth session state in Chrome extension local storage.

Severity
Medium unwanted
Type
Unexpected
CWE
CWE-829
Source
Dynamic sandbox
What actually happens
You did this

You install or start Kleo, which loads its background service worker.

The extension did this

The extension creates a Supabase backend client, refreshes sessions, and persists auth state in extension storage.

Captured request
POSThttps://uk.server2.data.kleo.so/auth/v1/token?grant_type=refresh_token

Dynamic analysis observed Supabase authentication traffic to the configured backend; no request body was recorded.

Backend and session fields configured by the extension
  • Supabase project host
    uk.server2.data.kleo.so

    This is the remote backend that receives the extension authentication traffic.

  • Bundled anonymous API key
    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJyb2xlIjogImFub24iLAogICJpc3MiOiAic3VwYWJhc2UiLAogICJpYXQiOiAxNzM0MzkzNjAwLAogICJleHAiOiAxODkyMTYwMDAwCn0.92Tf-L2njOh2gPu1J8RcmSwjn8yqvQv6NF1vmVHzX7I

    The key identifies the Supabase project role used by the extension when it talks to the backend.

  • Extension auth storage key
    kleo_auth

    Session state stored under this key can keep the extension signed in across browser restarts.

The code that does this

Supabase client and storage configuration

Readable version

Readable Supabase client setup

deobfuscated/static/background/index.js
  "5sSbd": [function(e, t, r) {    var s = e("@parcel/transformer-js/src/esmodule-helpers.js");    s.defineInteropFlag(r), s.export(r, "supabaseClient", () => o);    var i = e("@supabase/supabase-js"),      n = e("~lib/kleoStorage");    let o = (0, i.createClient)("https://uk.server2.data.kleo.so", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJyb2xlIjogImFub24iLAogICJpc3MiOiAic3VwYWJhc2UiLAogICJpYXQiOiAxNzM0MzkzNjAwLAogICJleHAiOiAxODkyMTYwMDAwCn0.92Tf-L2njOh2gPu1J8RcmSwjn8yqvQv6NF1vmVHzX7I", {      auth: {        storage: n.kleoStorage,        autoRefreshToken: !0,        persistSession: !0,        detectSessionInUrl: !0,        storageKey: "kleo_auth"      }    })

Readable storage adapter setup

deobfuscated/static/background/index.js
  j67dh: [function(e, t, r) {    var s = e("@parcel/transformer-js/src/esmodule-helpers.js");    s.defineInteropFlag(r), s.export(r, "kleoStorage", () => n);    var i = e("@plasmohq/storage");    let n = new i.Storage({      area: "local",      copiedKeyList: ["kleo_auth"]    })
Remote backend contacted by the auth client
    • uk.server2.data.kleo.so

    Supabase authentication and session-management backend configured in the extension source.

What it can do

Permissions this extension asks for, as declared in version 0.265.0. Asking for a permission is not a finding on its own - it is what the extension can do if it chooses to.

  • Read and change your data on linkedin.com

    *://*.linkedin.com/*

  • Read and change your data on kleo.so

    *://*.kleo.so/*

  • Store data in your browser

    storage

  • Run its own code inside the pages you visit

    scripting

  • Read and change cookies, including the ones that keep you signed in

    cookies

Updated 30 September 2026jdedmecbcembcmlddbchlmmoglcldghe