Is Save to Notion safe?
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.…
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
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 adds recent background request logs before uploading the feedback.
The upload happens only when the submitted feedback type is issue.
No feedback request body was captured during dynamic analysis; this method and URL come from the shipped feedback sender.
- Feedback typeissue
Marks the report as an issue, which is the path that adds background logs.
- Recent request URLhttps://www.notion.so/api/v3/loadPageChunk (illustrative)
Shows which extension-related web request happened before you submitted feedback.
- Request methodPOST
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.
Fetch logging, issue-log collection, and feedback upload
Readable fetch logger and log reader
deobfuscated/serviceWorker.js:2326-2403const 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-6063async 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-3495contentInside: { 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")}- 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
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 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.
Dynamic analysis observed a telemetry request to this endpoint; the request body was not captured.
- Action nametake_highlight
Shows whether you used the highlight tool or the screenshot tool.
- Page URL for a highlighthttps://www.example.com/research/project-notes?draft=1 (illustrative)
Identifies the page where you selected text for a highlight.
- Page URL for a screenshothttps://app.example.com/customers/acme/dashboard (illustrative)
Identifies the page where you captured a screenshot.
- Anonymous extension IDanonymous-8c3f2b9a-41d4-4ec5-b471-75d7fbf8a82b (illustrative)
Lets repeated telemetry events from the same browser installation be grouped together.
- Notion user IDd9f6a2b1-0f1a-4f45-a6f5-5586be37e1e3 (illustrative)
Links the telemetry to your signed-in Save to Notion account when that value is present.
Telemetry POST and the two user-action URL fields
Readable telemetry sender
deobfuscated/serviceWorker.js:3736-3755let 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-3831async 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-3891async 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)}- 6kbxs6snzg.execute-api.us-east-1.amazonaws.com
Save to Notion backend endpoint that receives extension telemetry events.