Is Save to Notion safe?

Medium risk

Save to Notion is medium risk. Submitting feedback in Save to Notion posts recent background request logs with the form: URLs, methods, bodies, headers, and up to 500 chars of response text. Dynamic analysis couldn't exercise the feedback UI to confirm this.…

savetonotion.sov4.3.10Chrome Web Store
49Risk

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

Publishers can request a review.

Findings

Issue feedback attaches recent extension request logs

Submitting feedback in Save to Notion posts recent background request logs with the form: URLs, methods, bodies, headers, and up to 500 chars of response text.

Dynamic analysis couldn't exercise the feedback UI to confirm this.

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

You submit an issue report from the extension's feedback UI.

The modal has an Issue choice and a Submit feedback button for the form.

The extension did this

The extension adds recent background request logs before uploading the feedback.

The upload happens only when the submitted feedback type is issue.

Captured request
POSThttps://6kbxs6snzg.execute-api.us-east-1.amazonaws.com/v1/integration/feedback

No feedback request body was captured during dynamic analysis; this method and URL come from the shipped feedback sender.

Fields that the feedback upload can carry for issue reports
  • Feedback type
    issue

    Marks the report as an issue, which is the path that adds background logs.

  • Recent request URL
    https://www.notion.so/api/v3/loadPageChunk (illustrative)

    Shows which extension-related web request happened before you submitted feedback.

  • Request method
    POST

    Shows how the recent request was made.

  • Request body
    {"pageId":"59833787-2cf9-4fdf-8782-e53db20768a5","limit":30} (illustrative)

    Can include data the extension sent in recent background requests.

  • Response text
    {"recordMap":{"block":{"59833787-2cf9-4fdf-8782-e53db20768a5":{"role":"reader"}}}} (illustrative)

    Can include the beginning of the server response returned to the extension.

The code that does this

Fetch logging, issue-log collection, and feedback upload

Readable version

Readable fetch logger and log reader

deobfuscated/serviceWorker.js:2326-2403
const Ya = 100;let St = new Array(Ya),  Fe = 0,  Xa = !1;function ur() {  const e = console.log,    t = console.error,    n = a => {      St[Fe] = a, Fe = (Fe + 1) % Ya, Fe === 0 && (Xa = !0)    };  console.fetchlog = (...a) => {    const o = {      timestamp: Date.now(),      type: "fetchlog",      source: "background",      message: a.map(i => typeof i == "object" ? JSON.stringify(i) : String(i))    };    n(o)  }, console.log = (...a) => {    const o = {      timestamp: Date.now(),      type: "log",      source: "background",      message: a.map(i => typeof i == "object" ? JSON.stringify(i) : String(i))    };    n(o), e.apply(console, a)  }, console.error = (...a) => {    const o = {      timestamp: Date.now(),      type: "error",      source: "background",      message: a.map(i => typeof i == "object" ? JSON.stringify(i) : String(i))    };    n(o), t.apply(console, a)  }}function dr() {  const e = fetch;  fetch = async (t, n) => {    const a = Date.now(),      o = t instanceof Request ? t.url : t.toString();    try {      const i = await e(t, n),        r = Date.now() - a,        c = i.clone();      let u = "";      try {        const g = await c.text();        u = g.length > 500 ? g.substring(0, 500) + "..." : g      } catch {        u = "Could not read response body"      }      return console.fetchlog(`FETCH ${i.status} ${o}`, `Duration: ${r}ms`, `Method: ${(n==null?void 0:n.method)||"GET"}`, `Body: ${JSON.stringify((n==null?void 0:n.body)||{})}`, `Headers: ${JSON.stringify((n==null?void 0:n.headers)||{})}`, `Response: ${u}`), i    } catch (i) {      const r = Date.now() - a;      let c = "";      if (i instanceof Response) try {        const u = await i.text();        c = u.length > 500 ? u.substring(0, 500) + "..." : u      } catch {        c = "Could not read error response body"      }      throw console.fetchlog(`FETCH ERROR ${o}`, `Duration: ${r}ms`, `Error: ${(i==null?void 0:i.message)||"Unknown error"}`, `Body: ${JSON.stringify((n==null?void 0:n.body)||{})}`, `Method: ${(n==null?void 0:n.method)||"GET"}`, `Headers: ${JSON.stringify((n==null?void 0:n.headers)||{})}`, `Response: ${c}`), i    }  }}function gr() {  return (Xa ? [...St.slice(Fe), ...St.slice(0, Fe)] : St.slice(0, Fe)).filter(t => t).map(t => ({    ...t,    message: t.message.map(n => {      const a = typeof n == "string" ? n : JSON.stringify(n);      return (t == null ? void 0 : t.type) == "fetchlog" ? a.substring(0, 18e3) : a.length > 2048 ? `${a.substring(0,2048)}...` : a    })  }))}

Readable issue-feedback sender

deobfuscated/serviceWorker.js:6044-6063
async function Zc(e) {}async function ya(e) {  try {    let t = {        ...e      },      n = [];    return e.type === "issue" && (n = [...await gr()]), e.logs && (n = [...n, ...e.logs]), n.length > 0 && (n.sort((i, s) => i.timestamp - s.timestamp), t.logs = n), await (await fetch(he.backend.url + "/integration/feedback", {      method: "POST",      body: JSON.stringify(t),      headers: {        "Content-Type": "application/json"      }    })).json()  } catch {    return {      success: !1    }  }}

Readable feedback UI choices

deobfuscated/modal.js:2588-2609,3487-3495
contentInside: {  width: "100%",  render: an(jn, {    placeholder: "other" == e.payload.type ? "What do you want us to know?" : "issue" === e.payload.type ? "I noticed that..." : "I would love...",    payload: t,    onChangePayload: n,    onSubmit: () => e.onSubmit(t)  })},quickNoteSaveButtonV2: {  disabled: !t.canSubmit,  onClick: () => e.onSubmit(t),  overrides: {    button: {      label: "Submit feedback"    }  }}issueButton: {  onClick: () => c("issue")},ideaButton: {  onClick: () => c("idea")},otherButton: {  onClick: () => c("other")}
Destination that receives issue feedback and attached logs
    • 6kbxs6snzg.execute-api.us-east-1.amazonaws.com

    Save to Notion backend endpoint that receives feedback submissions and, for issue reports, attached background logs.

Page URLs sent during highlight and screenshot actions

When you highlight text or take a screenshot, Save to Notion posts telemetry to its backend, including the event name and page URL where you used that feature.

Dynamic analysis saw a request to that endpoint but did not capture its body.

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

You highlight text or take a screenshot on a web page.

The source code routes context-menu and keyboard-command actions into the same highlight and screenshot handlers.

The extension did this

The extension posts a telemetry event that includes the page URL for that action.

The code uses different field names for the two actions: visitedPageUrl for highlights and visitedUrl for screenshots.

Captured request
POSThttps://6kbxs6snzg.execute-api.us-east-1.amazonaws.com/v1/integration/telemetry

Dynamic analysis observed a telemetry request to this endpoint; the request body was not captured.

Telemetry fields added by the highlight and screenshot handlers
  • Action name
    take_highlight

    Shows whether you used the highlight tool or the screenshot tool.

  • Page URL for a highlight
    https://www.example.com/research/project-notes?draft=1 (illustrative)

    Identifies the page where you selected text for a highlight.

  • Page URL for a screenshot
    https://app.example.com/customers/acme/dashboard (illustrative)

    Identifies the page where you captured a screenshot.

  • Anonymous extension ID
    anonymous-8c3f2b9a-41d4-4ec5-b471-75d7fbf8a82b (illustrative)

    Lets repeated telemetry events from the same browser installation be grouped together.

  • Notion user ID
    d9f6a2b1-0f1a-4f45-a6f5-5586be37e1e3 (illustrative)

    Links the telemetry to your signed-in Save to Notion account when that value is present.

The code that does this

Telemetry POST and the two user-action URL fields

Readable version

Readable telemetry sender

deobfuscated/serviceWorker.js:3736-3755
let ge = null;async function co(e) {  le || (ge || (ge = await m.info.load(), ge.anonymousUserId || (ge.anonymousUserId = `anonymous-${J()}`, await m.info.save({    ...ge  }))), console.log("tracking event", e.event, e.properties, ge.anonymousUserId, ge.userId), await fetch(`${he.backend.url}/integration/telemetry`, {    method: "POST",    keepalive: !0,    headers: {      "Content-Type": "application/json",      "x-anonymous-user-id": ge.anonymousUserId,      ...ge.userId ? {        "x-user-id": ge.userId      } : {}    },    body: JSON.stringify({      event: e.event,      properties: e.properties ?? {}    })  }))}

Readable highlight handler

deobfuscated/serviceWorker.js:3770-3831
async function lo(e, t, n, a) {  var y, w;  let o, i;  try {    if (n != null && n.endsWith(".pdf")) o = {      pageUrl: n,      selectionText: t    };    else if (o = await je("getSelectedText.js"), i = await io(), (!o.selectionText || o.selectionText.length == 0) && a && o.pageUrl.endsWith(".pdf")) {      alert("The shortcut for PDF highlighting is temporarily disabled. Please use the right-click context menu instead. We apologize for any inconvenience.");      return    }  } catch {    const b = await Y();    o = {      pageUrl: (b == null ? void 0 : b.url) ?? n,      selectionText: t    }  }  if (Bt("take_highlight", {      selectionText: t,      visitedPageUrl: o.pageUrl,      pdfFrameUrl: n,      isCommandShortcut: a    }), !go(o.selectionText)) {    Se("Highlight validation failed in takeHighlight function"), await R({      id: "highlight-validation",      type: "error",      message: "Please select some text to highlight"    });    return  }  const s = it(o.pageUrl);  if (await Tn(s)) {    await Jr({      pageUrl: s,      selectionText: o.selectionText    });    return  }  const c = await m.user.load(),    u = ((y = c == null ? void 0 : c.settings) == null ? void 0 : y.highlightFormat) || $.bullet,    g = ((w = c == null ? void 0 : c.settings) == null ? void 0 : w.highlightColor) || "default";  let p;  e === -1 && (p = await Y());  let h = e === -1 ? p == null ? void 0 : p.id : e;  if (lc(e, p)) {    alert("To highlight in PDFs, first open the clipper. This workaround addresses a known bug we're actively fixing. Thank you for your patience.");    return  }  await D.openPromptActionHighlight({    pageUrl: s,    selectionText: o.selectionText,    highlightFormat: u,    highlightColor: g,    selectionRange: i  }, {    tab: {      id: h    }  })}

Readable screenshot handler

deobfuscated/serviceWorker.js:3870-3891
async function Dt(e, t, n) {  Bt("take_screenshot", {    type: e,    visitedUrl: n  });  const a = await Ze(e);  if (!a.success) return;  const o = it(n),    i = await Tn(o);  await m.captureContext.save({    type: "highlight",    highlightContextData: {      type: "image",      text: `New Screenshot ${dc(new Date)}`,      image: {        imageBase64: a.imageBase64      },      url: n,      capturedWebpage: i    }  }), await Ce(t)}
Destination that receives the telemetry event
    • 6kbxs6snzg.execute-api.us-east-1.amazonaws.com

    Save to Notion backend endpoint that receives extension telemetry events.

Updated 30 September 2026ldmmifpegigmeammaeckplhnjbbpccmm