Is Kortex Notebook: Best Gemini NotebookLM Toolkit safe?
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.…
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
You click the extension's save button on a supported AI chat page.
The extension reads the visible conversation and sends it to NotebookLM as a text source.
- User messagesUser: Summarize the attached quarterly launch plan
Includes prompts or questions you typed into the AI chat page.
- Assistant repliesAssistant: The plan focuses on enterprise onboarding and partner launches
Includes responses shown by the AI service in the same conversation.
- Page URLhttps://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.
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.
- Content-Type
- application/x-www-form-urlencoded
- Credentials mode
- include
Chat page scrapers and NotebookLM upload path
ChatGPT scraper reads message roles and visible text
deobfuscated/assets/index.js-D5UE3FLa.jsscrapeConversation: 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.jsscrapeConversation: 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.jsasync 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;}- 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
You select or refresh a NotebookLM account in the extension.
The extension fetches NotebookLM, reads session token values, and stores them locally.
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.
- Credentials mode
- include
- SNlM0e tokenSNlM0e=<redacted>
Provides the token value later sent with NotebookLM API requests.
- cfb2h tokencfb2h=<redacted>
Provides the companion token value used to build NotebookLM API calls.
- Storage keykortex_notebooklm_tokens
Shows where the extension keeps the token pair on the device.
- Cache lifetime1 hour
Keeps the token pair available for reuse during the cache window.
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'
{ "alex.morgan@example.com": { "atToken": "<redacted>", "blToken": "<redacted>", "timestamp": 1720737600000 }}Token extraction and one-hour local cache
Refresh request from the popup
deobfuscated/assets/popup-B10NeUQ-.jsconst 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.jsfunction 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.jsasync 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
You open the extension's NotebookLM account selector.
The extension requests and parses the Google account list for the current browser session.
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.
- Credentials mode
- include
- Google email addressalex.morgan@example.com
Identifies each Google account available in your browser session.
- Display nameAlex Morgan
Adds the name Google associates with that account.
- Profile photo URLhttps://lh3.googleusercontent.com/a/ACg8ocK2ZpQ
Links the account record to a Google-hosted profile image.
- Active/default statusactive: true, default: false
Shows which account is active and which account Google marks as the default.
- Account index2
Lets the extension select a specific Google account for later NotebookLM requests.
Popup account request and background ListAccounts parser
Popup asks the background worker for accounts
deobfuscated/assets/popup-B10NeUQ-.jsconst 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.jsasync 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