Is WebSync for NotebookLM safe?
WebSync for NotebookLM is high risk. The import in WebSync for NotebookLM queries Firestore for active crawlSpecs matching the domain and visibility group. Specs hold JSON-encoded isolatedSteps/mainSteps sent to the tab, where the DOM runner reads markup, text, title, URL.…
Who publishes itDanJ - no other listings under this identity
DanJ - no other listings under this identity
What this publisher told the store about itself, and the other listings that told it the same thing.
AI-generated. Findings may contain errors. Those marked Verified have been manually reviewed.
Publishers can request a review.
Findings
Remote crawlSpecs run DOM steps in matching tabs
The import in WebSync for NotebookLM queries Firestore for active crawlSpecs matching the domain and visibility group.
Specs hold JSON-encoded isolatedSteps/mainSteps sent to the tab, where the DOM runner reads markup, text, title, URL.
- Severity
- High unwanted
- Type
- Unexpected
- CWE
- CWE-829
- Source
- Dynamic sandbox
You start an import action on the current page.
The command handler checks whether crawlSpecs exist before choosing link or content import.
The extension asks Firestore for matching crawlSpecs and runs the returned DOM steps in the tab.
The selected specs are filtered by domain, visibility, active status, spec version, and matchFilter.
- Matching domainen.wikipedia.org
Controls which site can receive the returned instructions.
- Page URLhttps://en.wikipedia.org/wiki/NotebookLM
Lets the instructions match and process the exact page you are importing.
- Page DOM content<main><h1>NotebookLM</h1><p>Article text</p></main>
Lets returned steps read page markup and text that may include the content you are viewing.
- Page titleNotebookLM - Wikipedia
Adds readable context about the page being imported.
- DOM step list[{"op":"innerHTML","var":"article","result":"html"},{"op":"sendContent","htmlVar":"html"}]
Defines what the content script does on the page after the server returns a matching spec.
No crawlSpecs response body was recorded in the unauthenticated traffic tests; source evidence shows this Firestore collection is queried when the import path reaches crawlSpec lookup.
The service worker fetches matching crawlSpecs and dispatches their steps
Readable converter parses crawlSpec step arrays
deobfuscated/assets/taskPermissions-ar2ti-M4.jspm = { toFirestore(e) { return { ...e } }, fromFirestore(e) { const t = e.data(); return { id: e.id, description: t.description, tooltip: t.tooltip, matchFilter: t.matchFilter, isolatedSteps: JSON.parse(t.isolatedSteps), mainSteps: t.mainSteps ? JSON.parse(t.mainSteps) : [], nameSteps: t.nameSteps ? JSON.parse(t.nameSteps) : [], preconditionSteps: t.preconditionSteps ? JSON.parse(t.preconditionSteps) : [], domain: t.domain, dependencies: t.dependencies ?? [], status: t.status, specVersion: t.specVersion, type: t.type, requiredHostPermissions: Qu(t.requiredHostPermissions), parameters: Qu(t.parameters), parametersSummary: t.parametersSummary && typeof t.parametersSummary == "object" ? t.parametersSummary : void 0, onAbortedSteps: Qu(t.onAbortedSteps), visibleTo: Array.isArray(t.visibleTo) ? t.visibleTo : [] } }};Readable lookup filters Firestore documents before use
deobfuscated/assets/taskPermissions-ar2ti-M4.jsconst km = async (e, t, r) => { try { me.info(`Getting crawl specs for ${e.hostname}`), await h3(e.hostname); const n = Ln(Oe, "crawlSpecs").withConverter(pm), a = gi.currentUser ? Fn(n, _r("domain", "==", e.hostname), _r("visibleTo", "array-contains-any", ["group:public", "group:lite", gi.currentUser.uid]), Lo("order", "asc")) : Fn(n, _r("domain", "==", e.hostname), _r("visibleTo", "array-contains", "group:public"), Lo("order", "asc")), s = (await Fo(a)).docs.filter(l => l.exists()).map(l => l.data()).filter(l => l.status === "active").filter(l => l.specVersion === 1).filter(l => new RegExp(l.matchFilter).test(e.href)), u = await f3(s, async l => l.preconditionSteps && l.preconditionSteps.length > 0 ? (me.debug("running precondition steps", l.preconditionSteps), await wm(l.preconditionSteps, r, void 0, t) ? (me.debug("precondition passed. including"), !0) : (me.debug("precondition failed. skipping"), !1)) : (me.debug("no precondition. including"), !0)); return me.debug(`returning ${u.length} specs`, u), u } catch (n) { return console.error("Error getting crawl specs", n), [] }};Readable dispatcher sends server-returned steps into the active tab
deobfuscated/assets/taskPermissions-ar2ti-M4.jsl3 = async (e, t, r, n, a = 1, i = !1, s, u) => { if (console.log("Injecting csDomDslRunner"), Xf("runDslForContent", { name: (t == null ? void 0 : t.domain) ?? "unknown", action: n ?? "unknown" }), !e.id) throw new Error("Tab must have id and url"); if (!e.url && !r && !t) throw new Error("Must provide a valid URL or spec or tab.url"); const l = t ? [t] : await km(r ?? new URL(e.url), s); for (const c of l) { if (n && c.type !== n) continue; await $s(e.id, !1); const d = u ?? c.onAbortedSteps, f = Qc(6), p = ["isolated"]; c.mainSteps && c.mainSteps.length > 0 && (await $s(e.id, !0), p.push("main")); const g = c3(e.id, f, p); me.info("Sending domDslRunnerStart to start running isolated steps"); try { await chrome.tabs.sendMessage(e.id, { action: "domDslRunnerStart", steps: c.isolatedSteps, runId: f, crawlSpecVersion: c.specVersion, collectLogs: i, userConfig: s, onAbortedSteps: d }) } catch (b) { if (g.cancel(), a > 0) return console.info("Failed to send domDslRunnerStart, removing attr and trying again", b), await chrome.scripting.executeScript({ target: { tabId: e.id }, func: () => { document.documentElement.removeAttribute("websync-extension-injected-isolated") } }), await l3(e, c, r, n, a - 1, i, s, d); throw console.error("Failed to send domDslRunnerStart", b), b } chrome.runtime.sendMessage({ action: "crawlSpecStarted", runId: f }).catch(b => { me.debug("Failed to send crawlSpecStarted notification", b) }), c.mainSteps && c.mainSteps.length > 0 && (me.info("Sending domDslRunnerProxyStartToMain to start running main steps"), await chrome.tabs.sendMessage(e.id, { action: "domDslRunnerProxyStartToMain", steps: c.mainSteps, runId: f, crawlSpecVersion: c.specVersion, collectLogs: i, userConfig: s, onAbortedSteps: d })), await g.promise }};The content-script runner executes received DOM operations
Readable runner starts the DSL program from extension messages
deobfuscated/csDomDslRunnerIsolated.jsconst $ = async g => { if (s.clear(), g.action === "domDslRunnerStart" && Array.isArray(g.steps)) try { const w = S(g.runId); if (console.log(`Received message to start running in ${h}`), g.steps.length === 0) { console.log(`No ${h} steps defined`), chrome.runtime.sendMessage({ action: "domDslRunnerFinished", runId: w, world: h, success: !0 }); return } console.log(`[id=${w}] Starting to run steps`, g.steps); const N = await cr(g.steps, w, { crawlSpecVersion: g.crawlSpecVersion, collectLogs: g.collectLogs, userConfig: g.userConfig, onAbortedSteps: g.onAbortedSteps, abortSignal: s }); chrome.runtime.sendMessage({ action: "domDslRunnerFinished", runId: w, world: h, success: N.success }) } catch (w) { console.error("Invalid DSL steps", w); const N = S(g.runId); chrome.runtime.sendMessage({ action: "domDslRunnerFinished", runId: N, world: h, success: !1 }) }};Readable operations collect DOM values and send content
deobfuscated/csDomDslRunnerIsolated.jscase "innerHTML": { c(r.result, ((f = n(r.var)) == null ? void 0 : f.innerHTML) || ""), u(a, r.result); break}case "innerText": { c(r.result, ((v = (b = n(r.var)) == null ? void 0 : b.innerText) == null ? void 0 : v.trim()) || ""), u(a, r.result); break}case "textContent": { c(r.result, ((D = (k = n(r.var)) == null ? void 0 : k.textContent) == null ? void 0 : D.trim()) || ""), u(a, r.result); break}case "sendContent": { const e = n(r.htmlVar); if (e && e.length > 0) { const t = r.titleVar ? n(r.titleVar) : document.title, o = r.urlVar ? n(r.urlVar) : void 0; Z(e, t, r.parseMethod, o), a.output = m({ htmlLength: e.length, title: t }) } else V(a, "sendContent called with empty html"); break}Readable content sender uses current page URL by default
deobfuscated/csDomDslRunnerIsolated.jsfunction Z(r, a, l = "node-html-markdown", f) { let v = 0; const k = f || window.location.href; for (; v * 5e4 < r.length;) w({ action: "addContent", url: k, html: r.slice(v * 5e4, (v + 1) * 5e4), title: a, parseMethod: l, hasMoreChunks: (v + 1) * 5e4 < r.length }), v++}- firestore.googleapis.com
Google Firestore API used by the extension project websync-ext-backend to return crawlSpec documents.
Crawl reports save page details to WebSync Firestore
Starting or syncing a crawl makes WebSync for NotebookLM write a crawl report under the user's ID in the vendor Firebase project.
Each page record has the URL, title, status, depth, content length, parse method.
No write body was captured.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You start or sync a crawl while signed in.
The extension prepares a per-page crawl report in the vendor Firestore project.
The report is keyed under the authenticated user ID and only starts when the reporting setting is enabled.
- Page URLhttps://www.wikipedia.org/wiki/NotebookLM (illustrative)
Shows the exact page included in the crawl report.
- Page titleNotebookLM - Wikipedia (illustrative)
Adds readable context about the page that was crawled.
- Crawl status and depthstatus: 200; depth: 1 (illustrative)
Shows whether the page was processed and how deep it was in the crawl.
- Content measurementshtmlLength: 128420; contentLength: 41877 (illustrative)
Shows how much page HTML and extracted content the extension processed.
- TimestampscreatedAt and updatedAt server timestamps
Records when the page record was created and updated.
Dynamic analysis observed Firestore connectivity, but no crawlReports write body was captured because the crawl and sync flow was not reached without a signed-in Google session.
The service worker writes crawl report documents
Readable crawl reporting class
deobfuscated/serviceWorker.jsclass mf { constructor() { X(this, "reportId", null); X(this, "lastReportId", null); X(this, "userId", null); X(this, "enabled", null); X(this, "startPromise", null); X(this, "pendingWrites", []) } setUser(t) { this.userId !== t && (this.userId = t, this.reportId = null, this.lastReportId = null, this.enabled = null, this.startPromise = null, this.pendingWrites = []) } getReportId() { return this.reportId } getLastReportId() { return this.reportId ?? this.lastReportId } enqueue(t) { if (!this.reportId) { this.pendingWrites.push(t); return } t() } async ensureEnabled() { if (!this.userId) return !1; if (this.enabled !== null) return this.enabled; try { const t = await Ya(this.userId); return this.enabled = (t == null ? void 0 : t.crawlReportsEnabled) ?? !1, this.enabled } catch (t) { return T.warn("Failed to load user settings for reporting", t), this.enabled = !1, !1 } } async startReport(t) { if (!this.userId) return null; if (this.reportId) { if (t.notebookId) { const a = ie(se, "users", this.userId, "crawlReports", this.reportId), s = un(t.crawlParams, t.postMethod, t.merge); Ae(a, { notebookId: t.notebookId, notebookName: t.notebookName ?? null, reason: t.reason, settings: s, updatedAt: R() }, { merge: !0 }) } return this.reportId } return this.startPromise ? this.startPromise : (this.startPromise = (async () => { if (!await this.ensureEnabled()) return this.pendingWrites = [], this.startPromise = null, null; const s = Ts(10), r = ie(se, "users", this.userId, "crawlReports", s), u = un(t.crawlParams, t.postMethod, t.merge); try { await Ae(r, { reportId: s, status: "running", reason: t.reason, notebookId: t.notebookId ?? null, notebookName: t.notebookName ?? null, settings: u, createdAt: R(), updatedAt: R(), schemaVersion: 1 }, { merge: !0 }), this.reportId = s; const i = [...this.pendingWrites]; return this.pendingWrites = [], i.forEach(f => f()), s } catch (i) { return T.warn("Failed to create crawl report", i), this.reportId = null, this.pendingWrites = [], null } finally { this.startPromise = null } })(), this.startPromise) } recordPage(t, a, s, r) { this.enqueue(() => { if (!this.reportId || !this.userId) return; const u = `page-${t.id}`, i = ie(se, "users", this.userId, "crawlReports", this.reportId, "pages", u); Ae(i, { pageId: t.id, url: t.url, title: t.title ?? null, status: t.status, depth: t.depth, htmlLength: a ?? null, contentLength: s ?? null, parseMethod: r ?? null, createdAt: R(), updatedAt: R() }, { merge: !0 }) }) } recordPageFailure(t, a, s) { this.enqueue(() => { if (!this.reportId || !this.userId) return; const r = `page-${rn(t)}`, u = ie(se, "users", this.userId, "crawlReports", this.reportId, "pages", r); Ae(u, { pageId: null, url: t, title: null, status: a ?? null, depth: null, htmlLength: null, contentLength: null, parseMethod: null, error: { code: s.code ?? null, message: s.message ?? null }, createdAt: R(), updatedAt: R() }, { merge: !0 }) }) } recordSourceStart(t, a, s, r, u) { this.enqueue(() => { if (!this.reportId || !this.userId) return; const i = `source-${t}`, f = ie(se, "users", this.userId, "crawlReports", this.reportId, "sources", i); Ae(f, { sourceIndex: t, pageIds: a, sourceName: s, postMethod: r, merge: u, status: "started", createdAt: R(), updatedAt: R() }, { merge: !0 }) }) } recordSourceResult(t, a) { this.enqueue(() => { var i, f, E; if (!this.reportId || !this.userId) return; const s = `source-${t}`, r = ie(se, "users", this.userId, "crawlReports", this.reportId, "sources", s), u = { status: a.status, updatedAt: R() }; if (a.nblmGuid !== void 0 && (u.nblmGuid = a.nblmGuid), a.nblmType !== void 0 && (u.nblmType = a.nblmType), a.error && (u.error = { code: a.error.code ?? null, message: a.error.message ?? null, snippet: nn(a.error.snippet) ?? null }), Ae(r, u, { merge: !0 }), a.status === "failed") { const h = ie(se, "users", this.userId, "crawlReports", this.reportId, "errors", `error-nblm-${t}`); Ae(h, { phase: "nblm", sourceIndex: t, error: { code: ((i = a.error) == null ? void 0 : i.code) ?? null, message: ((f = a.error) == null ? void 0 : f.message) ?? null, snippet: nn((E = a.error) == null ? void 0 : E.snippet) ?? null }, createdAt: R(), updatedAt: R() }, { merge: !0 }) } }) } recordError(t, a) { this.enqueue(() => { if (!this.reportId || !this.userId) return; const s = a.pageId !== void 0 && a.pageId !== null ? `page-${a.pageId}` : a.url ? `url-${rn(a.url)}` : Ts(6), r = ie(se, "users", this.userId, "crawlReports", this.reportId, "errors", `error-${t}-${s}`); Ae(r, { phase: t, pageId: a.pageId ?? null, url: a.url ?? null, error: { code: a.code ?? null, message: a.message ?? null }, createdAt: R(), updatedAt: R() }, { merge: !0 }) }) } finalize(t, a) { this.enqueue(() => { if (!this.reportId || !this.userId) return; const s = ie(se, "users", this.userId, "crawlReports", this.reportId), r = W(), u = { pagesCrawled: Z(r.crawlResults), pagesWithContent: Ha(r.crawlResults), pagesAttempted: r.crawledUrlsSize(), sourcesTotal: r.getResultsToSyncTotal(), sourcesStarted: r.getResultsToSyncStarted(), sourcesSucceeded: r.getResultsToSyncSucceeded() }; z(s, { status: t, counts: u, finishedAt: R(), updatedAt: R(), ...a ?? {} }), this.lastReportId = this.reportId, this.reportId = null, this.pendingWrites = [], this.startPromise = null }) }}- firestore.googleapis.com
Google Firestore endpoint for the websync-ext-backend Firebase project; receives crawl report documents under users/{uid}/crawlReports.
Popup scrape flow opens an inactive tab for DOM rules
The service worker handles a scrapeUrlInTab message by opening a tab for the URL, active state set by the message.
With no active tab requested, it opens inactive and runs the DOM crawl-spec runner.
Ordinary pages can't send this directly.
- Severity
- Medium unwanted
- Type
- Unexpected
- CWE
- CWE-200
- Source
- Dynamic sandbox
You start the extension popup scrape flow for a URL.
The service worker opens that URL in a tab that can remain inactive and runs DOM scraping rules after the page loads.
The dynamic test found that ordinary web pages could not invoke this internal message directly.
- Supplied URLhttps://www.wikipedia.org/wiki/NotebookLM (illustrative)
Determines which page is opened for the scrape flow.
- Page titleNotebookLM - Wikipedia (illustrative)
Adds readable context for the page that was processed.
- DOM-derived contentArticle heading and section text (illustrative)
Contains text and structured content extracted by the crawl-spec rules.
- Tab active flagactive: false when a.active is not true
Controls whether the opened tab is brought to the foreground or left in the background.
immediate
You didThe popup sends scrapeUrlInTab with a target URL.
The extension didThe service worker registers a load-complete listener for the new tab.
immediate
You didThe service worker creates the tab for the supplied URL.
The extension didThe tab can stay inactive unless the message explicitly requests an active tab.
on page load
You didThe tab reports that loading is complete.
The extension didThe DOM crawl-spec runner is sent to the loaded tab.
No NotebookLM upload body with scraped page content was captured during dynamic analysis; the attempted external page message was rejected before tab creation.
The service worker opens the supplied URL and runs the DOM rules
Readable scrapeUrlInTab branch
deobfuscated/serviceWorker.jselse if (a.action === "scrapeUrlInTab") { let l; const p = (b, I, S) => { l != null && b === l.id && I.status === "complete" && (T.log("Tab loaded:", S.url), (oe ? wa(oe.uid).then(O => O == null ? void 0 : O.collectCrawlSpecRunsLogs) : Promise.resolve(!1)).then(async O => { const F = a.crawlSpecId ? await vu(a.crawlSpecId) : void 0; if (a.crawlSpecId && !F) throw new Error(`scrapeUrlInTab requested crawlSpecId not found or inaccessible: ${a.crawlSpecId}`); const P = S.url ? new URL(S.url) : new URL(a.url); T.info("Running scrapeUrlInTab DSL", { tabId: S.id, loadedUrl: P.href, crawlSpecId: a.crawlSpecId ?? null, active: a.active === !0, explicitSpec: !!F }), await dt(S, F, P, void 0, 1, O, { ...Pt(C().userConfig) ?? {}, ...a.userConfig ?? {} }) }).catch(T.error), chrome.tabs.onUpdated.removeListener(p)) }; chrome.tabs.onUpdated.addListener(p), l = await chrome.tabs.create({ url: a.url, active: a.active === !0 }) }+1 more finding not shown