Is WebSync for NotebookLM safe?

High risk

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.…

DanJv2.5.20.26689Chrome Web Store
77Risk
Who publishes it

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.

Publisher
DanJ

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
What actually happens
You did this

You start an import action on the current page.

The command handler checks whether crawlSpecs exist before choosing link or content import.

The extension did this

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.

Remote crawlSpec fields consumed by the extension
  • Matching domain
    en.wikipedia.org

    Controls which site can receive the returned instructions.

  • Page URL
    https://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 title
    NotebookLM - 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.

Captured request
POSThttps://firestore.googleapis.com/v1/projects/websync-ext-backend/databases/(default)/documents/crawlSpecs

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 code that does this

The service worker fetches matching crawlSpecs and dispatches their steps

Readable version

Readable converter parses crawlSpec step arrays

deobfuscated/assets/taskPermissions-ar2ti-M4.js
pm = {  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.js
const 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.js
l3 = 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 code that does this

The content-script runner executes received DOM operations

Readable version

Readable runner starts the DSL program from extension messages

deobfuscated/csDomDslRunnerIsolated.js
const $ = 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.js
case "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.js
function 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++}
External service involved in the crawlSpec path
    • 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
What actually happens
You did this

You start or sync a crawl while signed in.

The extension did this

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.

Fields written for each successful page record
  • Page URL
    https://www.wikipedia.org/wiki/NotebookLM (illustrative)

    Shows the exact page included in the crawl report.

  • Page title
    NotebookLM - Wikipedia (illustrative)

    Adds readable context about the page that was crawled.

  • Crawl status and depth
    status: 200; depth: 1 (illustrative)

    Shows whether the page was processed and how deep it was in the crawl.

  • Content measurements
    htmlLength: 128420; contentLength: 41877 (illustrative)

    Shows how much page HTML and extracted content the extension processed.

  • Timestamps
    createdAt and updatedAt server timestamps

    Records when the page record was created and updated.

Captured request
POSThttps://firestore.googleapis.com/v1/projects/websync-ext-backend/databases/(default)/documents/users/{uid}/crawlReports/{reportId}/pages/{pageId}

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 code that does this

The service worker writes crawl report documents

Readable version

Readable crawl reporting class

deobfuscated/serviceWorker.js
class 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    })  }}
External service receiving the report records
    • 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
What actually happens
You did this

You start the extension popup scrape flow for a URL.

The extension did this

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.

Inputs and outputs of the tab scrape flow
  • Supplied URL
    https://www.wikipedia.org/wiki/NotebookLM (illustrative)

    Determines which page is opened for the scrape flow.

  • Page title
    NotebookLM - Wikipedia (illustrative)

    Adds readable context for the page that was processed.

  • DOM-derived content
    Article heading and section text (illustrative)

    Contains text and structured content extracted by the crawl-spec rules.

  • Tab active flag
    active: false when a.active is not true

    Controls whether the opened tab is brought to the foreground or left in the background.

How the internal scrape message maps to browser actions
  1. immediate

    You did

    The popup sends scrapeUrlInTab with a target URL.

    The extension did

    The service worker registers a load-complete listener for the new tab.

  2. immediate

    You did

    The service worker creates the tab for the supplied URL.

    The extension did

    The tab can stay inactive unless the message explicitly requests an active tab.

  3. on page load

    You did

    The tab reports that loading is complete.

    The extension did

    The DOM crawl-spec runner is sent to the loaded tab.

Captured request
POSThttps://notebooklm.google.com/upload/_/?authuser={account}

No NotebookLM upload body with scraped page content was captured during dynamic analysis; the attempted external page message was rejected before tab creation.

The code that does this

The service worker opens the supplied URL and runs the DOM rules

Readable version

Readable scrapeUrlInTab branch

deobfuscated/serviceWorker.js
else 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

Updated 30 September 2026hjoonjdnhagnpfgifhjolheimamcafok