Is Kortex Notebook: Best Gemini NotebookLM Toolkit safe?

High risk

Kortex is high risk. Using the save action on ChatGPT, Gemini, Claude, or Perplexity reads the visible conversation text, formats it with the page URL and source title, and sends it to NotebookLM's batchexecute endpoint as a new text source.…

yakshgandhi1v2.3.3Chrome Web Store
79Risk

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

Publishers can request a review.

Findings

AI chat transcripts forwarded to NotebookLM

Using the save action on ChatGPT, Gemini, Claude, or Perplexity reads the visible conversation text, formats it with the page URL and source title, and sends it to NotebookLM's batchexecute endpoint as a new text source.

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

You click the extension's save button on a supported AI chat page.

The extension did this

The extension reads the visible conversation and sends it to NotebookLM as a text source.

Conversation data assembled for the NotebookLM source
  • User messages
    User: Summarize the attached quarterly launch plan

    Includes prompts or questions you typed into the AI chat page.

  • Assistant replies
    Assistant: The plan focuses on enterprise onboarding and partner launches

    Includes responses shown by the AI service in the same conversation.

  • Page URL
    https://chatgpt.com/c/7b9f3d2a-9c4e-4f57-8c2e-31b8e7a6d0b4

    Shows which conversation page the saved source came from.

  • Source title
    [CHATGPT] Market research notes

    Adds a label that identifies the platform and browser page title in NotebookLM.

Captured request
POSThttps://notebooklm.google.com/_/LabsTailwindUi/data/batchexecute?rpcids=izAoDd&source-path=/&rt=c

The source code posts the formatted conversation through NotebookLM's ADD_TEXT_SOURCE RPC. Dynamic analysis did not record a NotebookLM batchexecute request body in the test session.

Headers
Content-Type
application/x-www-form-urlencoded
Credentials mode
include
The code that does this

Chat page scrapers and NotebookLM upload path

Readable version

ChatGPT scraper reads message roles and visible text

deobfuscated/assets/index.js-D5UE3FLa.js
scrapeConversation: function() {  if (document.querySelector(this.selectors.GROUP_CHAT_HEADER)) return this.scrapeGroupChatConversation();  const e = [];  document.querySelectorAll(this.selectors.TURN_CONTAINER).forEach(n => {    const r = n.querySelector(this.selectors.MESSAGE_ROLE), o = n.querySelector(this.selectors.MESSAGE_CONTENT);    r && o && e.push({      role: r.getAttribute("data-message-author-role"),      content: o.innerText    });  });  e.length === 0 && document.querySelectorAll("[data-message-author-role]").forEach(r => {    const o = r.getAttribute("data-message-author-role"), s = (r.querySelector(".markdown.prose, .markdown, .whitespace-pre-wrap") || r).innerText.trim();    o && s && e.push({      role: o,      content: s    });  });  return e;}

Gemini scraper reads user-query and model-response blocks

deobfuscated/assets/index.js-D5UE3FLa.js
scrapeConversation: function() {  const e = [], t = document.querySelector(this.selectors.CANVAS_CONTENT);  if (t) {    const r = t.innerText.trim();    r && e.push({      role: "model",      content: r    });  }  document.querySelectorAll(`${this.selectors.CONVERSATION_CONTAINER} > ${this.selectors.USER_QUERY_BLOCK}, ${this.selectors.CONVERSATION_CONTAINER} > ${this.selectors.MODEL_RESPONSE_BLOCK}`).forEach(r => {    let o = "", i = "";    if (r.tagName.toLowerCase() === this.selectors.USER_QUERY_BLOCK) {      o = "user";      const s = r.querySelector(this.selectors.USER_CONTENT);      s && (i = s.innerText.trim());    } else if (r.tagName.toLowerCase() === this.selectors.MODEL_RESPONSE_BLOCK) {      o = "model";      const s = r.querySelector(this.selectors.MODEL_CONTENT);      s && (i = s.innerText.trim());    }    o && i && !r.querySelector("immersive-panel") && e.push({      role: o,      content: i    });  });  return e;}

NotebookLM RPC sender

deobfuscated/assets/background-B03_osAI.js
async function de(n, i, e, t, s = "/", r = null) {  const o = (Math.floor(9e5 * Math.random()) + 1e5).toString(),    a = JSON.stringify([      [        [n, JSON.stringify(i), null, "generic"]      ]    ]);  console.log("[batchExecute] RPC Payload:", a);  let l = r;  l == null && (l = await He());  const c = new URL(Mr);  c.searchParams.set("rpcids", n);  c.searchParams.set("source-path", s);  c.searchParams.set("bl", t);  c.searchParams.set("_reqid", o);  c.searchParams.set("rt", "c");  l != null && c.searchParams.set("authuser", l.toString());  const y = await fetch(c.toString(), {    method: "POST",    credentials: "include",    headers: {      "Content-Type": "application/x-www-form-urlencoded"    },    body: `f.req=${encodeURIComponent(a)}&at=${e}`  });  if (!y.ok) throw new Error(`API Request failed: ${y.status}`);  return y;}
External service receiving the formatted source
    • notebooklm.google.com

    Receives the formatted AI chat transcript through the NotebookLM ADD_TEXT_SOURCE batchexecute request.

NotebookLM session tokens cached locally

After a NotebookLM account action, the extension fetches NotebookLM with browser credentials, reads tokens SNlM0e and cfb2h from the HTML, and stores them under kortex_notebooklm_tokens for reuse while fresh.

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

You select or refresh a NotebookLM account in the extension.

The extension did this

The extension fetches NotebookLM, reads session token values, and stores them locally.

Captured request
GEThttps://notebooklm.google.com/?authuser=0&pageId=none

The source searches the returned HTML for SNlM0e and cfb2h token fields. Dynamic analysis did not record this extension request in the test session, so no response body is quoted.

Headers
Credentials mode
include
NotebookLM credential fields handled by the extension
  • SNlM0e token
    SNlM0e=<redacted>

    Provides the token value later sent with NotebookLM API requests.

  • cfb2h token
    cfb2h=<redacted>

    Provides the companion token value used to build NotebookLM API calls.

  • Storage key
    kortex_notebooklm_tokens

    Shows where the extension keeps the token pair on the device.

  • Cache lifetime
    1 hour

    Keeps the token pair available for reuse during the cache window.

What's stored on your device

Stores NotebookLM credential material in local storage so later NotebookLM requests can reuse it during the cache window.

Location
chrome.storage.local key 'kortex_notebooklm_tokens'
Contents
{  "alex.morgan@example.com": {    "atToken": "<redacted>",    "blToken": "<redacted>",    "timestamp": 1720737600000  }}
The code that does this

Token extraction and one-hour local cache

Readable version

Refresh request from the popup

deobfuscated/assets/popup-B10NeUQ-.js
const c = await chrome.runtime.sendMessage({  type: "REFRESH_NOTEBOOKLM_TOKENS"});c && c.success ? W(u, true, "Account selected and verified.") : W(u, true, "Account selected. Please log into NotebookLM with this account in the opened tab.");

Token regex and NotebookLM fetch

deobfuscated/assets/background-B03_osAI.js
function mt(n, i) {  const t = new RegExp(`"${n}":"([^"]+)"`).exec(i);  return t ? t[1] : null;}async function ce() {  try {    const n = await Ae();    if (n) {      const l = await jt(n);      if (l && l.atToken && l.blToken) return E.debug("Using stored tokens for account", {        account: n      }), {        atToken: l.atToken,        blToken: l.blToken      };    }    const i = await He();    let e = st.notebooklm.baseUrl;    if (i != null) {      const l = new URL(e);      l.searchParams.set("authuser", i.toString());      l.searchParams.set("pageId", "none");      e = l.toString();    }    const t = await fetch(e, {      redirect: "error",      credentials: "include"    });    if (!t.ok) throw n ? new Error(`Please log in to NotebookLM with account ${n} in a separate tab first.`) : new Error("Please log in to NotebookLM in a separate tab first.");    const s = await t.text(), r = mt("SNlM0e", s), o = mt("cfb2h", s);    if (!r || !o) throw n ? new Error(`Could not find NotebookLM tokens. Please ensure you're logged into NotebookLM with account ${n}.`) : new Error("Could not find NotebookLM tokens.");    n && (await Vt(n, {      atToken: r,      blToken: o    }), E.debug("Stored tokens for account", {      account: n    }));    return {      atToken: r,      blToken: o    };  } catch (n) {    throw console.error("Auth failed:", n), n;  }}

Local token cache

deobfuscated/assets/background-B03_osAI.js
async function Vt(n, i) {  return new Promise(e => {    chrome.storage.local.get([Le], t => {      const s = t[Le] || {};      s[n] = {        ...i,        timestamp: Date.now()      };      chrome.storage.local.set({        [Le]: s      }, () => {        e();      });    });  });}async function jt(n) {  return new Promise(i => {    chrome.storage.local.get([Le], e => {      const s = (e[Le] || {})[n];      if (s && s.timestamp && Date.now() - s.timestamp < 36e5) {        i(s);        return;      }      i(null);    });  });}

Google accounts listed through ListAccounts

Opening the account selector queries Google's ListAccounts with credentials included, for accounts in the current session.

The list is parsed into local records: email, display name, photo URL, active/default status, account index.

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

You open the extension's NotebookLM account selector.

The extension did this

The extension requests and parses the Google account list for the current browser session.

Captured request
GEThttps://accounts.google.com/ListAccounts?json=standard&source=ogb&md=1&cc=1&mn=1&mo=1&gpsia=1&fwput=860&listPages=1&origin=https%3A%2F%2Fwww.google.com

The source reads the response as text and parses account records. Dynamic analysis did not record a ListAccounts request in the test session, so no response body is quoted.

Headers
Credentials mode
include
Account fields parsed from the ListAccounts response
  • Google email address
    alex.morgan@example.com

    Identifies each Google account available in your browser session.

  • Display name
    Alex Morgan

    Adds the name Google associates with that account.

  • Profile photo URL
    https://lh3.googleusercontent.com/a/ACg8ocK2ZpQ

    Links the account record to a Google-hosted profile image.

  • Active/default status
    active: true, default: false

    Shows which account is active and which account Google marks as the default.

  • Account index
    2

    Lets the extension select a specific Google account for later NotebookLM requests.

The code that does this

Popup account request and background ListAccounts parser

Readable version

Popup asks the background worker for accounts

deobfuscated/assets/popup-B10NeUQ-.js
const e = await chrome.runtime.sendMessage({  type: "GET_NOTEBOOKLM_ACCOUNTS"});if (console.log("Kortex: Accounts response:", e), e && e.success && e.accounts && e.accounts.length > 0) {  n.innerHTML = "";  e.accounts.forEach(l => {    const u = document.createElement("option");    u.value = l.index !== void 0 ? l.index.toString() : l.email;    let i = l.email;    l.detected && !l.stored && (i += " (detected)");    l.isDefault && (i += " (default)");    u.textContent = i;    u.dataset.email = l.email;    n.appendChild(u);  });}

Background fetches and parses ListAccounts

deobfuscated/assets/background-B03_osAI.js
async function Rr() {  try {    console.log("Kortex: Starting Google account detection via ListAccounts API...");    const n = await fetch("https://accounts.google.com/ListAccounts?json=standard&source=ogb&md=1&cc=1&mn=1&mo=1&gpsia=1&fwput=860&listPages=1&origin=https%3A%2F%2Fwww.google.com", {      credentials: "include"    });    if (!n.ok) return console.warn("Kortex: Failed to fetch accounts:", n.status, n.statusText), await Je();    const i = await n.text();    console.log("Kortex: ListAccounts response received, length:", i.length);    let e = null;    try {      e = JSON.parse(i), Array.isArray(e) && e.length > 1 && Array.isArray(e[1]) && console.log("Kortex: Response is direct JSON, using it directly");    } catch {}    let t = null;    if (!e) {      const o = [/postMessage\('(.*?)',\s*'https:/s, /postMessage\("(.*?)",\s*"https:/s, /postMessage\('(.*)',\s'https:/, /postMessage\("(.*)",\s"https:/, /var\s+accounts\s*=\s*(\[.*?\])/, /\["ListAccounts",\s*(\[.*?\])/, /accounts:\s*(\[.*?\])/];      for (const a of o)        if (t = a.exec(i), t && t[1]) {          console.log("Kortex: Found account data with pattern:", a.toString());          break;        }      if (!t || !t[1]) return console.warn("Kortex: No account data found in response. Trying fallback..."), console.log("Kortex: Response preview (first 1000 chars):", i.substring(0, 1e3)), await Je();    }    let s;    try {      if (e) s = e.length > 1 && Array.isArray(e[1]) ? e[1] : [];      else {        let o = t[1].replace(/\\x5b/g, "[").replace(/\\x5d/g, "]").replace(/\\x22/g, '"').replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\n/g, "").replace(/\\r/g, "");        try {          e = JSON.parse(o);        } catch (a) {          try {            e = JSON.parse(`[${o}]`);          } catch {            throw a;          }        }        Array.isArray(e) ? e.length > 1 && Array.isArray(e[1]) ? s = e[1] : (e.length > 0 && Array.isArray(e[0]) && e[0].length > 0 && typeof e[0][0] == "string" && e[0][0].startsWith("gaia.l.a"), s = e) : s = [];      }      console.log("Kortex: Parsed account data, count:", s.length, "First account:", s[0]);    } catch (o) {      return console.error("Kortex: Failed to parse account list:", o), console.log("Kortex: Raw match:", t[1].substring(0, 200)), await Je();    }    if (!Array.isArray(s) || s.length === 0) return console.warn("Kortex: No accounts in parsed data, trying fallback..."), await Je();    const r = s.map((o, a) => {      if (!Array.isArray(o) || o.length < 4) return console.warn("Kortex: Invalid account structure:", o), null;      if (o[0] !== "gaia.l.a") return console.warn('Kortex: Account does not start with "gaia.l.a":', o[0]), null;      const l = o[2] || null, c = o[3] || null, y = o[4] || null, f = o[5] === 1, k = o[6] === 1, m = typeof o[7] == "number" ? o[7] : a, x = o[10] || c || `account_${a}`;      return !c || typeof c != "string" ? (console.warn("Kortex: Invalid email in account:", o), null) : {        email: c.toLowerCase(),        id: x,        name: l || c.split("@")[0] || `Account ${a+1}`,        index: m,        isActive: f,        isDefault: k,        src: y,        detected: true      };    }).filter(o => o !== null && o.email && !o.email.includes("@unknown"));    return console.log("Kortex: Detected Google accounts via ListAccounts API:", r.length, r), r;  } catch (n) {    return console.error("Kortex: Error detecting Google accounts:", n), await Je();  }}

+2 more findings not shown

Updated 30 September 2026hdapplggdhndkblofffknpmnnnnbncbn