Commit graph

527 commits

Author SHA1 Message Date
Johannes Millan
6da578aa8d
fix(sync): defer LocalFile folder pick commit to settings Save (#9075) (#9085)
* fix(sync): defer LocalFile folder pick commit to settings Save (#9075)

* test(sync): pin Android SAF pick route through form value (#9075)
2026-07-16 19:11:02 +02:00
Johannes Millan
463709e2de
fix(electron): assert renderer IPC boundary at window creation (#9018)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(electron): assert renderer IPC boundary at window creation

Every IPC trust boundary (Jira one-shot capability, plugin node-exec
consent, the window.ea preload bridge) rests on the renderer main world
not having require/ipcRenderer, which is guaranteed solely by
contextIsolation: true + nodeIntegration: false and sub-frames not
getting node integration. If that webPreferences ever silently regressed
(a refactor spreading a shared options object, a bad merge), every gate
would collapse at once while still looking correct in review.

Add web-preferences-guard.ts (assertSecureWebPreferences) and fail closed
before creating a window if the boundary is not intact. It rejects a
non-true contextIsolation, a non-false nodeIntegration, and (fail-closed)
a nodeIntegrationInSubFrames that is not explicitly false; it also
directionally rejects an explicit sandbox: false, nodeIntegrationInWorker:
true, and webviewTag: true (each off by default, so no call site is
forced to set it). Wire it at all three new BrowserWindow sites (main
window, task widget, full-screen blocker); the full-screen blocker
previously relied on Electron defaults, so set its webPreferences
explicitly. A *.test.cjs backs it with behavioral coverage plus a wiring
guard that counts constructor sites vs guard calls per file, so a future
window cannot silently ship without the check.

Closes #9015

* fix(electron): extend webPreferences guard to webSecurity

Follow-up hardening from the multi-agent review of #9018:

- Reject an explicit `webSecurity: false` (directional, like the sandbox
  /worker/webviewTag trio). With the app's blanket
  Access-Control-Allow-Origin: *, disabling the same-origin policy in a
  node-bridged renderer would widen cross-origin reach — and no call site
  currently guards against it.
- Broaden the wiring-guard test to also require the assert for
  `new BrowserView` / `new WebContentsView`, closing the tripwire's blind
  spot for future non-BrowserWindow renderers (none exist today).
- Correct the fail() comment: the `throw` narrows the type regardless of
  return-vs-throw; fail() returns an Error only to DRY the message.

230/230 electron tests pass; checkFile + prettier clean.
2026-07-15 10:37:10 +02:00
Johannes Millan
95d3b212bc
fix(electron): gate Jira IPC behind a one-shot capability (#9008)
* docs(plugins): add microsoft 365 calendar provider plan

* fix(jira): gate Electron requests behind one-shot capability

Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources.

* fix(jira): enforce Electron request capability

Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations.

* fix(electron): handle payload-only IPC lifecycle

Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete.

* fix(electron): address Jira IPC capability review findings

- electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the
  payload-only IPC refactor (was [1], now undefined -> TypeError on every
  download); guard against a malformed payload
- jira-capability: rotate the token on re-register so a renderer reload
  that reuses the WebFrameMain object is not permanently locked out of
  Jira; invalidates any stale token
- document that the one-shot consumption order, not the bypassable
  main-frame check, is the real capability boundary
- jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip
  when image auth was never set up (non-Jira detail-panel open/close)
- jira-api: route a synchronous _toElectronRequestInit throw through
  _handleResponse instead of leaking a dangling request-log entry

* test(electron): cover ANY_FILE_DOWNLOADED payload parsing

Extract parseDownloadedFilePayload from ElectronEffects and add a
regression spec pinning the payload-only shape ([file], not [event,
file]) that caused a TypeError on every download. Hardened against
non-array input surfaced by the new test.

* fix(electron): restore Node ambient globals for frontend build
2026-07-14 21:02:27 +02:00
Johannes Millan
420292e37c feat(notes): allow outlook: + note/task-app deep-links in URL-scheme allowlist (#8859)
Restore outlook: (reported in #8859) plus the same-class note/task apps
notion:, things:, omnifocus:, bear:, joplin: that the GHSA-hr87 fix also
silently broke. All low-risk registered-app deep-links, same class as the
already-allowed obsidian:/logseq:/zotero:.
2026-07-08 17:12:09 +02:00
aakhter
0842ffe1fa
perf(planner): drop per-CD getters/allocations in planner-task and kb (#8808)
planner-task took a non-signal @Input task and exposed per-change-
detection getters (titleHasLinks ran a regex scan, timeEstimate, and
the isDone/isDragReady/isCurrent HostBinding getters) that re-executed
on every CD pass, per row. Convert task to a required signal input,
replace the getters with computed()s, and move the class bindings to
host metadata (mirrors schedule-event). The kb getters in task and
main-header allocated a fresh {} on the no-shortcuts path every CD
pass; return a shared frozen EMPTY_KEYBOARD_CONFIG via a pure
keyboardConfigOrEmpty helper so the value is referentially stable.
Behavior (current-task highlight, drag-ready, done styling, shortcut
hints, sub-task count) is unchanged.

SPAP-28
2026-07-07 11:49:46 +02:00
CJ Felux
85204b13c5
fix(menu): resolve camel case label in menu bar #8445 (#8744) 2026-07-06 17:22:51 +02:00
Johannes Millan
08d13fd7d5
fix(local-rest-api): validate PATCH/create field value types (#8738)
PATCH /tasks/:id picked allowed keys but never checked value types, so a
wrong-typed value (e.g. tagIds:123, timeEstimate:"abc") was written into
the store and the synced op-log, corrupting state locally and tripping
typia-as-corrupt on other devices when the op replays. Both PATCH and
create now typia.validate the values and return 400 before dispatching.

Also harden the Electron server: reset isListening eagerly and call
closeAllConnections() on stop so a keep-alive socket can't leave the
server stuck and no-op a later re-enable, reject requests with 503 while
disabled, and log an actionable message on EADDRINUSE.

Closes #8732
Refs #7484
2026-07-03 18:20:33 +02:00
Myk
310e8cf2db
fix(plugins): spawn nodeExecution scripts via process.execPath so packaged apps work #8707 (#8708) 2026-07-02 15:05:57 +02:00
Johannes Millan
4e0e2d9ffd
fix(electron): open file:// URLs with special characters on Windows (#8695) (#8698)
* fix(electron): open file:// URLs with special characters (#8695)

Local file:// URLs with non-ASCII names or spaces (e.g. Grüne, "Another
One") failed to open on Windows: both open sinks handed the URL to
shell.openExternal, which percent-encodes the path (ü → %C3%BC, space →
%20) before ShellExecute, so the OS then searched for a literally-named
folder and failed. Pure-ASCII paths worked because there was nothing to
encode.

Route local file: URLs through shell.openPath with a decoded filesystem
path (fileURLToPath) instead. openPath takes a raw path, so Unicode names
and spaces open correctly. This also applies the executable-extension
guard to file: URLs, which previously only ran on the OPEN_PATH sink
(GHSA-hr87-735w-hfq3).

Shared openLocalPath()/isLocalFileUrl() cover both the OPEN_EXTERNAL IPC
handler and the navigation-interception path in main-window.

* test(electron): cover NTLM/exec vectors at the un-pre-gated openPath sink

Follow-up to the #8695 review. Add regression tests locking in that
openLocalPath alone (OPEN_PATH has no scheme pre-gate) blocks the
path-based UNC file: URL (file:////host/share, GHSA-hr87-735w-hfq3) and
an executable hidden behind a percent-encoded dot (evil%2Ebat). Document
that the decode tests run on Linux and therefore don't exercise the
Windows drive-letter/backslash conversion, and clarify why isLocalFileUrl
is intentionally broader than the renderer/scheme-allowlist checks.
2026-07-02 11:47:51 +02:00
Johannes Millan
ad7564bad2
fix(electron): block executable launch via shell.openPath (#8668)
* fix(electron): block executable launch via shell.openPath

window.ea.openPath is reachable by any plugin, same-origin iframe plugin, or
renderer XSS, and its guard only blocked UNC/remote paths (NTLM leak,
GHSA-hr87). shell.openPath hands a local file to the OS handler, and on
Windows ShellExecute runs .bat/.cmd/.vbs/.hta/... from a plain file with no
exec bit. Chained with a writable-dir drop (e.g. FILE_SYNC_SAVE into the
local-file sync folder) or a malicious synced FILE attachment clicked by the
user, that is renderer -> native code execution bypassing the nodeExecution
consent gate.

Add hasExecutableFileExtension() (normalizes Windows trailing dots/spaces and
NTFS alternate data streams) and refuse those paths at the openPath sink.
Applied at the sink, not in the shared isPathSafeToOpen, so link/image
rendering (where a remote https://.../x.exe is legitimate) is unaffected.

* fix(electron): close # filename bypass in openPath executable gate

The executable-extension check split every path on `?`/`#` to drop a URL
query/fragment, but `#` is a legal Windows/NTFS filename char (and both `#`
and `?` are legal on POSIX). For a bare filesystem path that split truncated
the real filename: `C:\sync\evil.txt#.bat` was read as `.txt` and allowed,
yet ShellExecute runs it as `.bat` — defeating the gate via the same
drop-a-file-then-openPath vector the fix targets. Gate the query/fragment
split behind an actual `file:` scheme so real filenames keep their extension.

Also add well-known ShellExecute/launcher vectors the curated denylist
missed: settingcontent-ms, appref-ms, library-ms, wsc, chm, hlp, diagcab,
msix/appx family (Windows) and pkg, terminal, fileloc, inetloc (macOS).

Adds regression tests for both.

* fix(electron): require file:// (not bare file:) before query/fragment split

The executable-extension gate stripped a URL query/fragment for any string
starting with `file:`. On POSIX `#`, `?` and `:` are all legal filename
chars, so a file whose name merely starts with the literal `file:` (e.g.
`file:notes.txt#.sh`) was mis-parsed as a URL and its real `.sh`/`.desktop`
extension hidden. Require the `//` of an actual `file://` URL so only genuine
URLs get the query/fragment split; bare paths keep their real extension.
2026-07-01 17:56:24 +02:00
Johannes Millan
97e97042cd
fix(electron): remove exec IPC to close GHSA-256q (#8669)
* fix(electron): fail-safe exec confirmation dialog (GHSA-256q)

The EXEC confirmation was the only gate before an arbitrary shell command
runs with the user's privileges, but it did not fail safe:

- defaultId: 2 was out of range for a two-button dialog, leaving the
  focused default per-platform-undefined, so an accidental Enter could
  execute. Cancel is now defaultId + cancelId (Enter/Escape never runs).
- 'Remember my answer' defaulted to checked, so one careless click could
  whitelist a command to the silent allow-list forever. It is now opt-in.

Add electron/ipc-handlers/exec.test.cjs as a regression guard.

* docs(plugins): correct misleading plugin sandboxing claims

Docs claimed JS plugins run in 'isolated VM contexts' and iframes run
'without allow-same-origin' — both are false. JS plugins run in the host
renderer via new Function, and iframes use allow-same-origin (required for
#8467), so both can reach the privileged window.ea bridge. Align the docs
with the code (plugin-iframe.util.ts) and stress the trust model.

* fix(electron): fail closed on corrupt exec allow-list, cover error paths

The EXEC handler is wired to ipcMain.on (fire-and-forget), so a throw on a
corrupt allow-list surfaced as an unhandled promise rejection with no user
feedback. Wrap the handler body in try/catch and route failures through
errorHandlerWithFrontendInform (the same channel exec errors already use),
failing closed so a corrupt store never falls through to executing.

Expand the regression tests: assert the corrupt-config path informs the error
and runs nothing, cover exec-error routing, and guard allow-list append (an
overwrite regression that wipes remembered commands previously passed green).

* docs(plugins): document window.ea.exec shell path in trust model

The trust-model section implied executeNodeScript() was the only process
path; on desktop window.ea.exec also runs arbitrary shell commands via
child_process.exec behind a separate, weaker gate (confirmation dialog +
persistent allow-list, not the nodeExecution consent).

* test(electron): run exec security regression tests in CI

The test:electron runner globs electron/*.test.cjs (non-recursive), so the
guard at electron/ipc-handlers/exec.test.cjs was never executed by CI —
verified: the suite went 160 -> 168 tests once discovered. Move it to
electron/exec.test.cjs (matching every sibling electron test) and point
execModulePath at ipc-handlers/exec.ts.

* refactor(electron): narrow exec allow-list without an unsafe cast

Drop the `as string[]` cast that asserted away the very corruption the
Array.isArray guard is meant to catch; narrow the unknown value honestly so
the guard is a real type-check. Behavior is unchanged (falsy -> empty list,
truthy non-array -> fail closed).

* fix(electron): stop logging exec command content

Per CLAUDE.md rule 9 (log history is exportable, never log user content), a
command can carry a secret in its arguments; log a content-free line instead.
Also fold the duplicated exec-spawn into a single runCommand() helper so the
allow-listed and just-confirmed paths share one audited call site.

* fix(electron): remove exec IPC to close GHSA-256q at the root

window.ea.exec exposed arbitrary shell (child_process.exec) to the whole
renderer: JS plugins (new Function in the host realm), same-origin iframe
plugins (window.parent.ea), and any renderer XSS — bypassing the per-plugin
nodeExecution consent gate entirely. Its only consumer, COMMAND task
attachments, is dormant: no UI creates them (the edit dialog offers only
LINK/IMG/FILE).

Rather than guard a dormant RCE primitive, remove it: delete the EXEC IPC
handler + preload.exec + the ElectronAPI method + the directive's COMMAND
branch. This closes the vector for plugins, iframes, AND host-realm XSS at
once (a bootstrap handoff would only hide it from plugins), and supersedes
the earlier dialog hardening (that primitive no longer exists).

Keep the 'COMMAND' literal in the synced TaskAttachment type (removing a
synced union member breaks typia validation on peers/legacy data); a click
on a legacy COMMAND attachment now shows an informational snack instead of
executing.

* docs(plugins): reflect exec IPC removal in the trust model

window.ea.exec no longer exists (removed to close GHSA-256q); the docs now
state executeNodeScript is the only sanctioned native-code path.

* test(electron): guard exec IPC stays removed (GHSA-256q)

The interim exec security tests were deleted together with the executor, so the
shipped fix had no regression coverage. Add electron/exec.test.cjs (picked up by
the electron/*.test.cjs CI glob) asserting the bare exec primitive stays gone:
no IPC.EXEC event, no exec.ts handler, no preload exec bridge, no ElectronAPI.exec
method, no initExecIpc wiring. Targets only the removed surface, not the
sanctioned PLUGIN_EXEC_NODE_SCRIPT/executeScript nodeExecution path.

* chore(electron): mark ALLOWED_COMMANDS store key as legacy

Its only reader/writer was the deleted exec handler (GHSA-256q). Document that it
is retained purely so older persisted stores keep loading.
2026-07-01 17:55:35 +02:00
Johannes Millan
c7d6131db8
feat(plainspace): Collaborate-on-Plainspace discovery + smoother connect (#8649)
* feat(plainspace): add Collaborate action to project context menu

Surface Plainspace sharing from the project context menu (active, non-Inbox,
not-yet-shared projects) so it can be discovered at the moment of collaboration
intent, reusing PlainspaceShareService. A new selectIsProjectSharedOnPlainspace
selector hides the action once a project is shared to avoid provisioning a
duplicate space on a repeat click.

* docs(plainspace): document collaboration in wiki

Add Plainspace to the issue-integration comparison (matrix + per-provider
section) and a how-to for sharing a project via the project menu, including the
network/privacy caveat.

* feat(plainspace): place Collaborate action below the share-list item

Group the two share/export actions and lift Collaborate higher for
discoverability. Gated to active, non-inbox, not-already-shared projects.

* fix(plainspace): smoother first-run connect and value-first dialog

Pre-check connectivity and revalidate a stored token before the space
picker, so a stale/foreign token routes to the connect dialog and an
offline state shows a calm message instead of the raw 'check your token'
picker error. Make the connect dialog value-first: lead with what you
get, drop the 4-step how-to and email hint in favor of one short pointer
(token-creation guidance moves to plainspace.org).

* docs(plans): dedicated from-Super-Productivity flow on plainspace.org

Open plan for a guided token/connect flow on plainspace.org when a user
arrives from SP (Model A manual token, Model B OAuth-style handoff).

* refactor(plainspace): drop redundant connect pre-check (multi-review)

The space picker already detects a stale token and offers a reconnect
(#8616), so the revalidate() pre-check duplicated that path, cost an
extra GET /me, and forced re-auth on a valid token during a transient
server blip. Keep only the one-line offline guard; drop revalidate(),
the discriminated union, and the unnecessary _isOnline() seam (navigator
.onLine is spyable in the runner). Fix stale doc comments and document
the disabled-provider trade-off in the selector.

* feat(plainspace): show brand icon in the connect dialog title

* feat(plainspace): deep-link connect dialog to the from-SP onboarding flow

Point the dialog's 'Open Plainspace' link at the dedicated
/connect/super-productivity entrypoint (which guides token creation)
instead of the bare marketing host, closing the connect-flow funnel leak.

* feat(plainspace): bounce back to the app after connecting (desktop)

Append a validated `?return=superproductivity://plainspace-connect` deep
link to the connect URL, gated on IS_ELECTRON (only desktop registers the
scheme — web/mobile would get dead buttons). Handle that action in the
Electron protocol handler by surfacing the window, so the connect page's
"Open Super Productivity" button re-focuses the app.

Desktop-only; needs an on-device check.
2026-07-01 16:39:34 +02:00
Johannes Millan
14a56f861e
feat(electron): trigger global shortcuts via superproductivity:// URLs (#8645)
* feat(electron): trigger global shortcuts via superproductivity:// URLs

On Wayland the compositor owns global hotkeys, so Electron's globalShortcut
often does not register. Add three actions — toggle-visibility, new-note and
new-task — to the existing protocol handler so a compositor keybind can call
`xdg-open superproductivity://<action>`. No extra CLI tool or runtime is
needed: xdg-open (Linux), open (macOS) and start (Windows) already ship with
the OS, and the running instance receives the URL via the existing
single-instance / second-instance path.

Extract the show/hide logic into a shared toggleWindowVisibility() used by both
the globalShowHide shortcut and the new protocol action, and add a key-repeat
debounce so one held key press no longer hides then immediately re-shows the
window.

Docs: add a Wayland keybind recipe (Niri/sway/Hyprland) to the keyboard
shortcuts wiki page.

Refs #7114

* fix(electron): correct toggle-visibility focus race and debounce

On Linux/Windows the second-instance handler pre-focused the window before processProtocolUrl ran, so toggle-visibility always read 'visible' and hid the window the user asked to show. Skip that pre-focus for toggle-visibility only (new getProtocolAction helper); every other action keeps the bring-to-front behavior.

Make the key-repeat debounce direction-agnostic with a sliding quiet-gap (1000->750ms): it now guards both show and hide, settles a held key on a single toggle, and fires on the xdg-open path where the old isHidden-only guard was always false.

Stop logging the create-task title and URL path to the exportable log (CLAUDE.md rule 9 / privacy).

Add coverage for the real second-instance path, held-key-from-hidden, gap expiry, the #7282 minimize fallback, the macOS hide path, unfocused-show, and the log redaction; document AppImage/Flatpak/Snap scheme registration. Refs #7114.

* fix(electron): show window on cold-start toggle-visibility launch

Cold start: when superproductivity://toggle-visibility launches the app (it wasn't running), the freshly-shown window was immediately hidden again because the toggle saw it focused. Flag the cold-start URL during the argv scan and SHOW — never toggle — the window once it's ready (via processPendingProtocolUrls), respecting start-minimized-to-tray. The already-running second-instance path keeps real toggle behavior.

Rename the two interactive protocol actions new-task/new-note to add-task/add-note: aligns with the app's Add-Task vocabulary and the globalAddTask/globalAddNote keys, and avoids colliding with the programmatic create-task/<title>. The action names are a frozen public contract once users bind them in compositor configs, so this is the pre-merge moment to settle the naming. Refs #7114.
2026-06-30 17:22:30 +02:00
oon arfiandwi
5ea0570f39
feat(plugins): generic OAuth hooks for issue-provider plugins (#8546)
* feat(plugins): enable OAuth-based issue-provider plugins

Generic, provider-agnostic plugin-framework hooks so an issue-provider plugin
that needs an exact OAuth redirect can work without any built-in code:

- OAuthFlowConfig.redirectUri: a plugin may declare an exact pre-registered
  callback; the host uses it for both the authorize request and token exchange.
- The Electron loopback honors a plugin-requested fixed port and rejects with a
  clear message when that port is already in use.
- Apply user-supplied clientId/clientSecret/redirectUri overrides onto a
  plugin's oauthConfig (bring-your-own OAuth app).
- Fix: merging a partial pluginConfig update no longer drops omitted keys and
  deep-merges nested objects (e.g. twoWaySync).

Split out of the Basecamp community-plugin work per PR #8507 feedback; contains
no provider-specific code.

* fix(plugins): address #8546 review

- validate OAuthFlowConfig.redirectUri per platform (loopback / same-origin /
  app scheme) and fail fast instead of hanging; restrict desktop loopback to 127.0.0.1
- warn when a client secret is dropped on web/native (bring-your-own credentials)
- validate the IPC loopback port to [1024,65535], register the error handler before
  listen(), and close the failed server
- merge pluginConfig via generic recursion instead of a hardcoded twoWaySync case
- nits: named token-store imports; fix stale prepareRedirectUri comment
- tests: redirectUri validation, the web client-secret warning, and generic merge

* fix(plugins): address #8546 round-2 review

- loopback error handler calls cleanupServer() so a post-listen runtime error
  doesn't leave the server ref / 5-min timer dangling
- share OAUTH_LOOPBACK_PORT_{MIN,MAX} between the renderer and Electron main so
  the bounds never drift; reject out-of-range (incl. 0/80/443) redirect ports early
- drop _getElectronLoopbackPort and parse the already-validated redirectUri once
- document that a bring-your-own clientSecret syncs via pluginConfig (override boundary)
- skip __proto__/constructor/prototype keys in the pluginConfig merge (defense-in-depth)
- test: prototype-pollution guard

* fix(plugins): address #8546 round-3 review

- reject native redirectUri overrides outright (closes CodeQL
  js/incomplete-url-scheme-check) via a pure, per-platform validateOAuthRedirectUri
  util (electron loopback / native reject / web same-origin)
- gate bring-your-own OAuth credentials to the desktop loopback flow and warn
  (instead of silently dropping clientId) when set on web/native
- namespace BYO under pluginConfig.oauthOverrides (was flat keys); document the
  convention on OAuthFlowConfig (public plugin API)
- shallow top-level pluginConfig merge: drop the deep recursion + proto guard;
  nested objects (e.g. twoWaySync) are replaced wholesale, matching callers
- pin the web redirect to /assets/oauth-callback.html via a shared constant so a
  same-origin wrong-path URI fails fast; note the desktop 127.0.0.1-only rule
- companion tests for each

* fix(plugins): strip desktop redirectUri on web/native OAuth flows

A plugin-declared redirectUri is the desktop loopback override; keeping it on
the web/native branches made a web/native-capable plugin throw at connect time
(the loopback URI fails web/native redirectUri validation). Strip it on those
branches so prepareRedirectUri falls through to the platform default. Document
redirectUri as desktop-only in the plugin API and fix a misleading test name.

* refactor(plugins): extract resolveEffectiveOAuthConfig and harden native fallthrough

Move the platform client/secret/redirectUri selection out of the bridge into a
pure, parameterized util so every branch is unit-testable (the IS_* platform
consts are module-level and cannot be mocked in karma). Also strip clientSecret
and redirectUri on the native fall-through — a native platform where the plugin
ships no matching client id — keeping both strictly desktop-only.

Optional hardening on top of the redirectUri fix; safe to drop independently.
2026-06-30 14:11:00 +02:00
Voronchikhin Ivan
c5fa02561e
fix(electron): use display name for Linux notifications (#8640) 2026-06-30 14:01:01 +02:00
Johannes Millan
7182ff4fba
feat(plugins): persist nodeExecution consent per plugin (#8512) (#8600)
* feat(plugins): persist nodeExecution consent per plugin (#8512)

Phase 2 of #8512: remember an uploaded plugin's nodeExecution consent so
it is asked once, not every app session, while keeping the trust decision
local to the device.

- Main-owned, local-only store (electron/plugin-node-consent-store.ts,
  wrapping simple-store under key 'pluginNodeExecutionConsent'); never
  pfapi-synced, so a grant on one device never auto-grants on another.
- Ask-once is scoped to UPLOADED plugins; built-in plugins (sync-md) keep
  the per-session verified prompt unchanged (regression-safe).
- Consent is written only after a native Allow in main; the renderer has a
  delete-only clearConsent IPC (fail-safe) and no way to self-grant.
- Cleared on disable / uninstall / re-upload (never in generic teardown),
  so revoke = the existing disable toggle and changed code re-consents.
- No code hash: re-ask-on-change is structural (re-upload clears consent);
  a renderer hash would be forgeable and security-worthless. version:1 is
  the migration anchor if main-owned hashing is ever added.

Tests: electron 154 pass (consent-store + executor ask-once/deny/clear/
built-in-never-persisted); 474 plugin specs pass incl. the sync-exclusion
guard. Docs updated.

* fix(plugins): harden persisted consent against prototype-pollution ids

Multi-review (security) found a CRITICAL in the Phase 2 consent store: the
consents map was a plain object keyed on an attacker-controlled pluginId, so
an uploaded plugin with id 'constructor' / 'toString' / 'valueOf' /
'hasOwnProperty' resolved consents[id] to the inherited Object.prototype
member (a truthy function). The executor's ask-once check treated that as a
prior grant and minted a nodeExecution token with NO consent dialog on a fresh
install — full code execution with zero user approval. ('__proto__' was already
blocked by the id allowlist; these names pass it.) Unit tests missed it because
the executor test stub used a Map, which is immune to the footgun.

Fix:
- Store: null-prototype consents map (Object.create(null)) + own-property
  (hasOwnProperty) guarded reads + reject non-object entries. Closes the class
  for any prototype-member id, including across a disk round-trip.
- Executor: reject __proto__/prototype/constructor in assertSafePluginId as
  defense-in-depth at the boundary.
- Regression tests: consent store returns null for prototype-member ids (fresh,
  after a real set, after clear); grant request for these ids is rejected with
  no dialog and no mint.

Also from review: ask-once path now re-checks the sender URL after the consent
read (parity with the dialog path); clarified why the consent mutation queue is
not redundant with simple-store's save queue.

Electron suite 156/156 pass.

* refactor(plugins): log consent persist-failure via electron-log

Multi-review follow-ups (non-blocking):
- Route the best-effort consent persist-failure to electron-log/main (the
  user-exportable host log) instead of console; console in the executor is
  otherwise the sandboxed plugin's own output. Only the validated id is logged.
- Clarify the disable-path comment: clearing revokes the live session grant
  always, and the persisted consent only for uploaded plugins (built-ins have
  none).

* fix(plugins): clear persisted consent on cache-clear and disclose persistence in dialog

Two gaps found in multi-agent review of the Phase 2 persisted-consent feature:

- clearUploadedPluginsFromMemory() (the 'Clear plugin cache' button) wiped the
  plugin code from IndexedDB but left the main-owned persisted nodeExecution
  consent behind. A later re-upload of the same id has no existingState, so the
  re-upload consent-clear in loadPluginFromZip never fired and the (possibly
  different) code was silently granted node execution with no prompt — defeating
  the 'replacing code under an id always re-asks' invariant. Now clears consent
  for every evicted uploaded id, mirroring removeUploadedPlugin.

- The uploaded-plugin native consent dialog still said access was valid 'for this
  app session', but Allow is now persisted across sessions. The prompt now
  discloses that the choice is remembered on the device until disable / remove /
  re-upload, so the user consents to the actual scope.

Regression tests added on both sides.

* refactor(plugins): key persisted consent on a Map, not a null-prototype object

Multi-review simplification. The consent store keyed an attacker-controlled
pluginId into a plain object, defended against `Object.prototype` member names
(constructor/toString/…) with a null-prototype object + hasOwn guards + a
typeof-object read check. A `Map` makes that safety structural and self-evident —
an unstored key is simply `undefined` — and matches the sibling `grants` Map in
the executor. The on-disk format is unchanged (a plain {version, consents} object);
the Map is serialized via Object.fromEntries (define-semantics, no prototype write)
and rebuilt via Object.entries with the well-formedness guard moved to load time.

Also corrects the stale 'never downgrade-corrupt it' comment with the accurate
downgrade behavior, and adds a round-trip test proving a hand-edited on-disk
__proto__ data key loads inertly without polluting Object.prototype.

* refactor(plugins): funnel disable through PluginService.disablePlugin and de-dup dialog display

Two more multi-review items, now that we own the PR:

- 'Disabling a node plugin revokes its consent' previously lived only in the
  plugin-management UI handler, so a future programmatic disable path could unload
  the plugin yet leave persisted consent behind — re-enabling would then silently
  re-grant node execution. Added PluginService.disablePlugin(setEnabled=false +
  unload + clearNodeExecutionConsent) and routed the UI through it, making the
  revoke a structural invariant. The consent clear is a safe no-op for non-node
  plugins, so the previous requiresNodeExecution gate is dropped.

- The uploaded-plugin name/version were sanitized once for the dialog and again for
  persistence (same lengths/fallbacks, duplicated). Extracted sanitizedUploadedDisplay
  as the single source of truth so the persisted record always matches what the user
  saw in the prompt.

Tests added for the disablePlugin invariant.

* fix(plugins): harden persisted nodeExecution consent (multi-review)

Follow-ups from a multi-agent review of #8600:

- Re-ask structurally on every upload: clear consent unconditionally in
  loadPluginFromZip (outside the `existingState` branch) so a same-id
  re-upload always re-prompts even if consent was orphaned (crash
  mid-uninstall, IndexedDB eviction, external/partial wipe).
- Fail closed on upload: clearNodeExecutionConsent reports a persist failure
  via its return value; loadPluginFromZip aborts the upload if the prior
  consent could not be revoked, so replacement code can't inherit a stale
  grant. Lifecycle edges (disable/uninstall/cache-clear) ignore the result so
  a rare disk failure can't abort their bookkeeping.
- Mint the grant before the best-effort consent persist in the executor so a
  navigation/destroy during the write drops it via cleanup and a persist
  failure can't lose an approved grant.
- Validate the full consent record shape on load so a corrupt {}/array entry
  can't read as a grant.
- Log only the validated id + error code on persist failure (no userData path).
- Fix a stale comment (the store keys consent in a Map, not null-proto objects).

Adds regression tests: mint-before-persist ordering, best-effort persist,
malformed-entry rejection, and the consent-clear fail-closed return contract.

* test(plugins): add clearNodeExecutionConsent to PluginBridgeService spy

loadPluginFromZip now clears prior persisted nodeExecution consent
before loading replacement code (#8512 Phase 2). The spy in this spec
lacked the method, so the call threw, was caught, returned false, and
aborted the upload, failing both load-from-zip tests.
2026-06-29 16:12:00 +02:00
Johannes Millan
a67323b2b5
feat(plugins): allow uploaded nodeExecution behind consent gate (#8576)
* feat(plugins): allow uploaded nodeExecution behind consent gate

Re-open the nodeExecution permission for uploaded/community plugins
(previously built-in only, #8205) behind the existing main-process
consent dialog. Phase 1 of #8512; unblocks the Super Productivity MCP
plugin (discussion #8385).

- Main process sanitizes the attacker-controlled plugin id and the
  self-declared name/version before they reach the consent dialog or
  the grant map (control/bidi/whitespace rejected, length-capped).
- Bundled vs uploaded is decided by the on-disk manifest, never a
  renderer-supplied flag, so uploaded code can't borrow a built-in
  plugin's verified name.
- Uploaded-plugin dialog anchors on the validated id, flags the plugin
  as unverified third-party with full machine access / no sandbox, and
  defaults to Deny.
- Revoke is main-authoritative by (pluginId, webContents) so a re-upload
  reusing an id can't inherit a live session grant.
- Consent stays session-scoped: an in-memory, never-synced denied set
  prevents re-prompt storms; deny keeps the plugin enabled but fails
  node calls closed until re-enable or restart.

Refs #8512 #8385

* fix(plugins): reject path-segment ids in nodeExecution consent gate

Multi-agent review of the Phase 1 gate found the uploaded plugin id —
used as a path component in the bundled-manifest existsSync probe — was
not rejecting path separators or dot-segments. Impact was bounded (the
verified-builtin branch re-validates with the strict kebab regex before
any read, so no code exec / file read / dialog spoof), but `..` / `/`
left a filesystem-existence oracle and rendered misleadingly as the
dialog "Plugin ID". assertSafePluginId now rejects `/`, `\`, `.`, `..`.

Also from review: factor the shared Allow/Deny dialog shell, and correct
the denied-cache comment (the existing token short-circuit handles the
multi-call-site case; the cache only makes a denial sticky across a later
non-interactive grant re-entry). Documents the uploaded-id constraints.

Refs #8512

* fix(plugins): harden uploaded nodeExecution consent gate (review follow-ups)

Addresses multi-agent review findings on the uploaded-plugin nodeExecution
consent gate:

- id validation: use an allowlist (/^[A-Za-z0-9][A-Za-z0-9._-]*$/) instead of a
  Unicode denylist, closing bidi/zero-width/homoglyph dialog-anchor spoofing the
  range list missed (U+061C, U+2060, U+3164, fullwidth chars); strip all Unicode
  control+format chars from the self-declared display name/version.
- never upgrade trust: describeVerifiedBuiltInDialog returns null on any imperfect
  on-disk verification (id mismatch, missing permission, unreadable manifest) and
  the grant handler falls back to the unverified dialog, so a colliding uploaded id
  can never borrow a built-in's verified dialog.
- reserve the gitea/linear/trello/azure-devops issue-provider bundled ids (they had
  drifted out of BUNDLED_PLUGIN_IDS, leaving an impersonation gap) and guard the
  BUNDLED_PLUGIN_PATHS subset-of BUNDLED_PLUGIN_IDS invariant with a node test.
- key the revoke and exec IPC handlers through the same assertSafePluginId as the
  grant handler so the "revoke by id on teardown/re-upload" guarantee can't drift.
- clear the session nodeExecution denial when a plugin is uninstalled, so a fresh
  re-upload of the same id is prompted again rather than silently failing closed.
- de-duplicate the PluginNodeExecutionElectronApi interface into a single
  electron/shared-with-frontend model (was copied byte-identically in two files).
2026-06-25 16:45:54 +02:00
aakhter
677d4aa57f
Auto-link raw webexteams URIs (#8560)
* feat: auto-link webexteams URIs

* test: expect escaped webexteams URIs

---------

Co-authored-by: Aamer Akhter <aamer_akhter@users.noreply.bitbucket.org>
2026-06-24 11:07:56 +02:00
Harbinger
8171bb05d0
refactor(sync): remove unused error classes, dialog, and constructor-time logging (phase 1, #8325) (#8510)
* fix(op-log): lock snapshot save to prevent lost-update window

saveCurrentStateAsSnapshot() read NgRx state then lastSeq without
holding OPERATION_LOG lock. An op appended between the two reads would
get seq <= lastAppliedOpSeq but its effect would be absent from the
snapshot. On next hydration the tail replay would start after that seq,
silently skipping the op forever.

Fix: wrap in lockService.request(LOCK_NAMES.OPERATION_LOG, ...) and
read lastSeq BEFORE state snapshot so the worst interleaving degrades
to harmless re-replay (idempotent) rather than a missed op.

Fixes #8308

* fix(op-log): address review feedback on snapshot lock PR

- Amend JSDoc idempotency claim: syncTimeSpent is additive on re-replay
- Add inline note about compaction's opposite read order and worse failure mode
- Add lock regression tests (#8308): lock acquired, read order, error handling

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(sync): remove unused error classes, dialog, and constructor-time logging

Phase 1 of #8325: clean up orphaned sync error types and their UI.

Removed error classes that are no longer thrown anywhere:
- NoEtagAPIError, FileExistsAPIError (unused API errors)
- RevMismatchForModelError, SyncInvalidTimeValuesError (superseded by file-based flow)
- RevMapModelMismatchErrorOnDownload/Upload, NoRemoteModelFile, NoRemoteMetaFile
- LockPresentError, LockFromLocalClientPresentError, MetaNotReadyError, InvalidRevMapError

Removed DialogSyncErrorComponent and all references in SyncWrapperService
(_forceDownload, _handleIncoherentTimestampsDialog, _handleIncompleteSyncDialog,
_openSyncErrorDialog, _extractModelIdFromError).

Removed constructor-time logging from JsonParseError, ModelValidationError,
DataValidationFailedError — these errors are logged at the catch site; redundant
construction-time logs risk leaking user data.

Cleaned up dead translation keys (D_INCOMPLETE_SYNC block, DIALOG_RESULT_ERROR,
ERROR_DATA_IS_CURRENTLY_WRITTEN) from en.json and t.const.ts.

Updated file-based-sync-flowchart.md to reflect the removed error types.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 13:36:56 +02:00
Johannes Millan
f7278a43ec
fix(electron): crisp, flicker-free Linux tray icon (#4905) (#8484)
Linux StatusNotifierItem hosts redraw the whole tray item on every
Tray.setImage(), so cycling the 16 progress-animation frames made the
icon flicker between a full and a partial ring. Show a single static
running icon on Linux; Windows/macOS keep the animation.

Also re-render the static stopped/running icons (light+dark, @2x) from
vector sources at 24/48px so GNOME no longer upscales a 16px source.
2026-06-19 12:43:00 +02:00
Johannes Millan
b2a68eddee
fix(electron): restore custom title bar on GNOME-X11 (#8483) (#8485)
#7097 force-disabled the custom title bar on all GNOME and hid the
toggle, even though only GNOME+Wayland actually fails to render the
Window-Controls-Overlay. This stranded GNOME-X11 users (where the
feature worked) with no way to re-enable it.

- Narrow the kill-switch from IS_GNOME_DESKTOP to a new IS_GNOME_WAYLAND
  (XDG_SESSION_TYPE/WAYLAND_DISPLAY, mirroring idle-time-handler). GNOME
  on X11 keeps the feature and the settings toggle.
- Unify GNOME detection: preload imports it from common.const instead of
  re-implementing, so main and renderer can't drift.
- Align global-theme.service with main-window: force native decorations
  on GNOME+Wayland in both, so a persisted/synced `true` no longer lays
  the custom header over native controls (doubled header).
- Add unit tests for the detection matrix.
2026-06-19 12:42:44 +02:00
Johannes Millan
31b7c3aac7
fix(electron): show tray icon on versioned Windows builds (#7282) (#8470)
A Windows tray-icon GUID binds to the executable's full path unless the
binary is code-signed with an organization in the signature subject. The
Store (MSIX) and portable/scoop builds run from versioned directories
whose path changes on every update, so the GUID goes stale and
Shell_NotifyIcon(NIM_ADD) fails silently (Electron raises no JS error).
new Tray(image, guid) still returns a live Tray, so the try/catch and
ensureIndicator() truthiness guards never detected the failure — the
window hid to an invisible, unreachable tray.

Use the path-bound GUID only for the stable-path NSIS build; create the
tray without a GUID on Store and portable, so Windows identifies the icon
by window handle and it stays visible across updates. The NSIS path is
untouched, preserving its taskbar-pin persistence.

Refs: https://www.electronjs.org/docs/latest/api/tray (guid)
2026-06-18 13:23:13 +02:00
Maikel Hajiabadi
ec16757c82
fix(keyboard): resolve macOS global shortcut layout mismatch (#8378) (#8381)
* fix(keyboard): resolve macOS global shortcut layout mismatch (#8378)

* fix(keyboard-layout): log layout-detection failure and resolve layoutReady with map copy

* fix(keyboard-shortcut): remove debug console logs and add macOS scope comment

* fix(keyboard-shortcut): preserve modifier separator when mapping plus key shortcut

* refactor(keyboard-shortcut): export mapping helpers and avoid as any cast in configuration mapping

* test(keyboard-shortcut): add unit tests for layout shortcut translation logic

* test(keyboard-shortcut): use correct KeyboardConfig type instead of as any in test fixture

* docs(keyboard-shortcut): hoist macOS physical shortcut layout comments to helper JSDoc

* refactor(config): extract global shortcut keys and mapping helpers

* test: add test helper capability for IS_ELECTRON and IS_MAC

* test: add comprehensive integration unit tests for global shortcut effects

* feat(electron): eagerly trigger layout detection on Electron startup for macOS timing fix

* refactor(config): InjectionToken migration, layout detection hardening, and startup optimization

* refactor: address non-blocking suggestions for layout detection and DI tokens

* build(electron): move keyboard-config.model to shared-with-frontend to fix ASAR require

* fix(client-id): increase entropy to 6 chars and fix related test regressions
2026-06-17 12:57:47 +02:00
Johannes Millan
a4bde69ece fix(electron): allow obsidian:// and other app deep-link schemes (#8429)
The GHSA-hr87 scheme allowlist (#8210, v18.10.0) permitted only
http/https/mailto/file, which silently broke custom app deep-links such
as obsidian:// in task-attachment links and markdown notes.

Add low-risk app-launch + standard schemes (obsidian, vscode,
vscode-insiders, zotero, logseq, tel, sms) to the shared allowlist. This
restores the links across all sinks (OPEN_EXTERNAL, OPEN_PATH, the
navigation fallback, and the markdown link renderer) while still blocking
the OS-level handlers (ms-msdt:, search-ms:) the allowlist exists to stop.
2026-06-16 11:52:44 +02:00
Johannes Millan
b3f7be0afc fix(electron): guard sync-folder cache against stale-load clobber
If a folder pick (setSyncFolderPath) completes while the very first
getSyncFolderPath disk read is still in flight, the load's resolution
callback would overwrite the freshly-set cache with the now-stale disk
value (null), leaving sync wrongly not-ready until restart. Only seed
the cache from disk when it's still unset; the freshly-picked value wins.
2026-06-10 20:35:00 +02:00
Johannes Millan
ecd5ec7077 Merge master and address PR review findings
Harden LocalFile saves, keep legacy file image IPC removed, avoid pre-save cached-image deletion, and add user-facing reselect warnings for LocalFile/background images.
2026-06-10 19:17:54 +02:00
Johannes Millan
0286cd3b5d feat(electron): restore bmp/avif background image support 2026-06-10 17:04:26 +02:00
Johannes Millan
9b58b883af refactor(electron): pick-time userData rejection + shared store key 2026-06-10 17:04:07 +02:00
Johannes Millan
86bb66b3b4 fix(electron): surface sync-folder pick persist failures to renderer 2026-06-10 17:02:48 +02:00
Johannes Millan
789214640d Merge origin/master into task/issue-8228-68b7fe
Resolve conflict in electron/local-file-sync.ts by keeping the PR side:
master's assertPathOutside guard was added inside the TO_FILE_URL handler,
which this PR deletes entirely (deletion supersedes the guard). Also drop
master's two new TO_FILE_URL tests (the handler no longer exists).
2026-06-10 16:12:07 +02:00
Johannes Millan
36dedbd8c4 Merge remote-tracking branch 'origin/master' into fix/security-followups-ntlm-iframe
* origin/master: (24 commits)
  fix(sync): keep own sync settings on hydration replay (#8245)
  Allow writable plugin calendar events to be rescheduled from Schedule (#7965)
  build(lint): enforce Log helpers over console.* in src/app (#8239)
  Fix minor cal design (#8227)
  feat(search): add completed task filter (#8168)
  fix(redmine): support non-latin issue search #4149 (#8150)
  fix(tasks): show schedule context for deadline reminders #7308 (#8165)
  test(task-repeat): derive today assertions from mocked DateService date
  test(task-repeat-cfg): fix date-dependent today assertion
  fix(android): bias torn bridge-thread reads safe, fix stale timer docs
  feat(i18n): update German translations (#8234)
  fix(android): render timer notifications via chronometer, drop 1Hz loops
  refactor(sync): derive local-only sync settings from a single key list #8233 (#8235)
  fix(planner): don't double-count timed recurring tasks on Today (#8236)
  fix(sync): keep schedule settings local (#8077)
  fix(planner): don't double-count recurring tasks that already have an instance (#8229)
  fix(electron): lock in-window navigation to the app's loaded origin (#8230)
  feat(shortcuts): add optional shortcuts for scheduling #8093 (#8189)
  refactor(planner): replace tomorrow task toPromise usage (#8202)
  fix: harden local file URL guard
  ...

# Conflicts:
#	src/app/plugins/ui/plugin-index/plugin-index.component.ts
2026-06-10 15:52:24 +02:00
johannesjo
2b628a0a50 refactor(electron): consistency + UX nits from pre-merge review
Pre-merge review of #8228 flagged four real items in the otherwise
solid security boundary. All non-blocking; rolled into one pass:

1) IMAGE_PICK_AND_IMPORT was the only renderer-callable handler that
   threw raw Errors back across IPC. The FS handlers all funnel
   through createSafeIpcError, which strips e.stack so main-bundle
   paths can't leak via the renderer's error-handling. Switch
   IMAGE_PICK_AND_IMPORT to the same shape: returns Error on
   validation failure, null on user cancel, value on success.
   Renderer adapter uses `instanceof Error` (parity with fileSyncLoad
   et al). New test asserts the returned error has no path content
   and no stack.

2) The image picker button was not disabled while the IPC was in
   flight. A second click would queue a second IPC, opening a second
   dialog after the first resolves; because `replacesId` is computed
   from the form value at click time, the first import's id would
   never be GC'd. Add an `isPickerBusy` signal and bind the button's
   `disabled` to it. New spec asserts the second concurrent click is a
   no-op and the busy flag clears in the `finally`.

3) Stale comment in `local-file.model.ts` pointed at
   `electron/sync-folder-store.ts`, which doesn't exist (the storage
   was inlined into local-file-sync.ts in Phase 2.5). Update to
   describe the actual current shape and clarify that the field is
   read once on upgrade for the migration breadcrumb and never written
   from new code paths.

4) Image input placeholder advertised `file://` URLs as a valid
   input. Phase 4 removed the IPC that resolved them, so the
   placeholder was a footgun. Drop the `file://` half.

68/68 electron security tests; 8/8 formly-image-input karma; lint +
tsc clean.
2026-06-10 00:17:48 +02:00
johannesjo
38d2b16a82 refactor(electron): close IMAGE_CACHE_IMPORT trigger gap + review nits
End-to-end review of the #8228 sweep flagged one real residual risk
and several smaller items. This commit addresses them in one pass.

The IMAGE_CACHE_IMPORT IPC accepted a renderer-supplied absolute path
with no link to a recent SHOW_OPEN_DIALOG signal. A compromised
renderer could call importImage() with any image-extension path under
5 MB outside userData — same information-disclosure shape the legacy
READ_LOCAL_IMAGE_AS_DATA_URL had, just one-shot instead of repeatable.

Fix: merge the dialog and the import into a single atomic IPC,
IMAGE_PICK_AND_IMPORT. Main opens the picker inline and only calls
importImage() with the file the user actually clicked. The renderer
no longer holds a path at any point in the flow; an attacker who
controls the renderer cannot trigger an image read without a real
user dialog interaction.

Shape change:
- Return null on user-cancel; throw on validation failure. This
  preserves the existing UX where cancel is silent and an unreadable
  image shows a snack. Renderer-side openFileExplorer is a try/catch.
- Optional { replacesId } arg garbage-collects the previously
  cached image when the user picks a new one. Closes the disk-fills
  follow-up the review raised (#3) without an extra IPC round-trip.

Smaller items from the review folded in:
- Extract _resolveRelative helper in local-file-sync.ts; the five
  FS handlers had the same three-line incantation copy-pasted. The
  Phase 2.5 simplification over-corrected by inlining everything.
- Drop the dead __resetSyncFolderCacheForTests export; no caller, and
  the tests reset via require.cache surgery anyway.
- image-cache.ts getExt now uses path.extname so Windows backslash
  paths are handled correctly (the old slash-only split worked by
  accident).
- resolveBgImageToDataUrl: short-circuit on empty id, and log a
  console.warn when a legacy file:// is detected (no resolvable IPC
  remains; user must re-pick). i18n snack is a documented follow-up.
- Package LocalFileSyncElectron.isReady now logs a critical line via
  the injected sync logger when isReady=false but the renderer's
  privateCfg still has the legacy syncFolderPath — dev console
  surfaces the "needs re-pick" state without dragging i18n into the
  security PR.
- Stale comment in electron-file-adapter.ts referenced a deleted file
  (sync-folder-store.ts is inlined into local-file-sync.ts now).

Tests:
- electron/local-file-sync.test.cjs gains four IPC-level cases:
  legacy IMAGE_CACHE_IMPORT not registered, pick+import round-trip,
  cancel returns null, validation failure throws, replacesId GCs the
  prior cached file.
- formly-image-input.component.spec rewritten for the new flow:
  asserts imagePickAndImport is called with replacesId when one is in
  state, snack on rejection but not on null, no setValue on either.

68/68 electron security tests pass; 7/7 formly-image-input karma;
5/5 resolve-bg-image karma; 17/17 sync-providers package
local-file specs. Lint + tsc clean.

What this commit does NOT do (called out so it isn't forgotten):
- CLIPBOARD_COPY_IMAGE_FILE has the same renderer-supplied-path shape
  for clipboard images. Parallel issue; out of scope for #8228.
- PICK_DIRECTORY has no defaultPath — could nudge users toward a
  dedicated sync subfolder vs picking ~/Documents. Cosmetic.
- Symlink TOCTOU between resolveSyncPath and writeFileSync stands;
  O_NOFOLLOW-on-open is the proper fix and needs cross-platform care.
- Migration UX is console-only; a user-visible snack needs i18n
  infrastructure not appropriate for the security commit.
2026-06-09 23:57:45 +02:00
johannesjo
ecb72fbc4a feat(electron): drop legacy renderer-supplied-path image IPCs (Phase 4)
With Phase 3's image cache in place, the picker no longer hands the
renderer an absolute path or produces file:// URLs. The two legacy
IPCs that still accepted renderer-supplied absolute paths are now
dead surface area:

  - READ_LOCAL_IMAGE_AS_DATA_URL — read any image-extension file
    outside userData and inline it as base64. The renderer could
    swap the stored value for any matching path between renders.
  - TO_FILE_URL — convert any path outside userData into a file://
    URL the renderer could embed. Was the precondition for the
    above.

Both are removed entirely (handlers, preload bindings, type
signatures, IPC enum entries). The remaining image flow is:

  showOpenDialog (proven user intent)
    → imageCacheImport(path) — main copies into private cache
    → renderer stores `image:<id>`
    → imageCacheGetDataUrl(id) per render

Migration: existing configs that hold `file://` values from older
builds render no background until the user re-picks. The resolver
util explicitly detects the legacy shape and returns null with a
comment pointing to this commit. Same migration model we used for
the sync folder (Phase 2): a one-time re-pick is the cost of
closing the security gap, and the picker UI is already in place.

Also drops the now-stale `IS_ELECTRON` early-return in the resolver
util — `window.ea?.imageCacheGetDataUrl` is undefined on web,
short-circuits naturally, and the implicit check made the spec
unable to exercise the Electron branch.

Tests updated: assertion that the legacy handlers are no longer
registered (a compromised renderer that still tries the IPC must
see no handler), and a positive `image:<id>` round-trip in the
karma util spec. 65 electron security tests + 5 util spec all
green; lint + tsc clean.
2026-06-09 23:40:47 +02:00
johannesjo
601a26c1f7 feat(electron): image picker copy-to-cache (Phase 3 of #8228)
The background-image picker previously gave the renderer an absolute
path to an arbitrary image-extension file and stored it as a file://
URL in user config. The render path then asked main to inline that
file:// on every theme apply. Main only refused paths inside userData,
so a compromised renderer could swap the stored value to any png/jpg/
gif/webp/svg path outside userData and re-read it for as long as it
stayed in config.

Switch the picker to copy-to-cache, serve by opaque id:

- electron/image-cache.ts (new): main-owned directory at
  `userData/bg-images/<id>.<ext>`. importImage(absolutePath) validates
  the source (outside userData, allowed extension, 5 MB cap, non-empty,
  is-file), copies it in, and returns a `randomBytes(16)` hex id. The
  id is unguessable so a renderer cannot iterate the cache directory
  for files it didn't import. getImageDataUrl(id) reads the cached
  file and returns a `data:<mime>;base64,…` URL, or null.

- electron/local-file-sync.ts: two new IPCs, IMAGE_CACHE_IMPORT and
  IMAGE_CACHE_GET_DATA_URL, registered next to the existing image
  handlers.

- electronAPI.d.ts + preload.ts: imageCacheImport / imageCacheGetDataUrl
  bindings.

- formly-image-input.component: after showOpenDialog returns a path,
  call imageCacheImport(path) and store `image:<id>` in the form
  control. Surface the existing "couldn't read image" snack on
  rejection. Removes the toFileUrl round-trip and removes the
  absolute-path leak into user config.

- resolve-bg-image-to-data-url.util: handle the `image:<id>` prefix by
  calling imageCacheGetDataUrl. Legacy `file://` values are still
  accepted for back-compat (the existing userData-guarded
  READ_LOCAL_IMAGE_AS_DATA_URL handles them); since the picker no
  longer produces new file:// values, the long-tail drains as users
  re-pick. A future commit can remove the legacy code path.

Tests:
- electron/image-cache.test.cjs: 12 cases — happy path, in-userData
  rejection, bad extension, too-large / empty / missing source,
  non-string input, unknown / malformed id, cross-restart persistence,
  removeCachedImage.
- electron/local-file-sync.test.cjs: 3 new IPC-level cases for
  IMAGE_CACHE_IMPORT (round-trip, userData refusal) and
  IMAGE_CACHE_GET_DATA_URL (unknown id).
- formly-image-input.component.spec: rewritten to assert
  imageCacheImport is called and `image:<id>` is what lands in the
  form, plus a failure-path snack assertion.

67 of 67 electron security tests pass; 6 of 6 formly-image-input
karma tests pass; lint and tsc clean.
2026-06-09 23:28:14 +02:00
johannesjo
00e71b407f refactor(electron): simplify sync folder ownership + stop renderer writes
Post-review of Phase 2 (#8228). The reviewer flagged one real bug and
several pieces of premature scaffolding.

Real bug — finding #1: the sync form was still writing the sync folder
path back into the renderer credential store. Phase 2 made the IPCs
take a relative path and main own the canonical root, but the Formly
button at sync-form.const.ts:200 had `key: 'syncFolderPath'`, which
caused FormlyBtn.onClick to setValue(picker-return-string) on the
form model. That flowed into privateCfg.syncFolderPath on save. The
"renderer no longer holds the authoritative copy" claim was true in
the IPC contract but untrue at runtime. A compromised renderer could
keep mutating its own privateCfg.syncFolderPath; nothing on master
reads it after Phase 2, but any future code path that does would
re-open the hole. Fix:
- drop `syncFolderPath` from PROVIDER_FIELD_DEFAULTS[LocalFile] so it
  is not in the LocalFile credential-store defaults at all
- drop `key: 'syncFolderPath'` from the LocalFile picker button so the
  picker's return value is not bound to the form model

KISS simplifications (a/b/d from review):
- delete electron/sync-folder-store.ts and its test; inline the
  load/cache/persist helpers at the top of local-file-sync.ts as
  ~25 LOC. The dedicated module was carrying an init/get/set ceremony
  and a Promise singleton for a single string that is read at most a
  few times per second in the worst case. simple-store is the durable
  layer either way.
- drop the `_resolveOrThrow` typeof precheck; resolveSyncPath already
  type-checks `relativePath` and throws the same opaque error
- inline `resolveSyncPath(...).absolutePath` into each handler; the
  helper was the third layer doing the same thing

Other findings addressed:
- finding #2 (PICK_DIRECTORY silently undefined on persist failure):
  PICK_DIRECTORY now returns a safe Error via createSafeIpcError when
  realpath/persist fails, distinct from `undefined` for user-cancel.
  New tests cover both branches plus the "cache must not be poisoned
  with a non-existent path" invariant.
- finding #4 (set-without-canonicalize): the inlined
  setSyncFolderPath now `realpathSync.native`s the picked path before
  persisting, so a relative or symlinked picker result is rejected
  early and the on-disk form matches what resolveSyncPath compares
  against on read.
- finding #5 (fire-and-forget init that was also being awaited per
  handler): dropped the fire-and-forget; handlers just `await
  getSyncFolderPath()`, which lazy-loads from disk on first call and
  caches.

All 52 electron security tests still pass; lint clean; tsc clean.
2026-06-09 23:15:53 +02:00
johannesjo
0c21649fde feat(electron): own sync folder main-side and take only relative paths
Phase 2 of #8228. Closes the renderer-supplied-absolute-path hole: the
file-sync IPCs now take a relative path; main resolves it against a
sync folder it owns and never trusts the renderer's copy of.

Main side
- sync-folder-store: persists the path in simple-store (still inside
  userData, still renderer-unreachable via assertPathOutside) with an
  in-memory cache so per-IPC lookup doesn't stat the file every call.
  initSyncFolderStore() is fire-and-forget at adapter init; each handler
  awaits it defensively because it's idempotent via _loadOnce.
- FILE_SYNC_SAVE/LOAD/REMOVE: accept { relativePath, ... }, pass through
  resolveSyncPath, write/read/delete the vetted absolute path.
- CHECK_DIR_EXISTS and FILE_SYNC_LIST_FILES: accept an optional
  relativePath; default to the sync root itself so the existing
  "is the sync folder reachable?" check still works.
- PICK_DIRECTORY: now persists the chosen folder via setSyncFolderPath
  before returning the display string to the renderer. The renderer
  receiving the string is no longer load-bearing — it's display only.
- New GET_SYNC_FOLDER_PATH IPC returns the current value for display
  (settings form, "sync folder: …" labels).
- TO_FILE_URL: pulls in the assertPathOutside(userData) backstop that
  was missing on master, so a userData path cannot be laundered into a
  file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL or
  the navigation guard.

Package (@sp/sync-providers/local-file)
- LocalFileSyncElectron.isReady now asks main (getMainSyncFolderPath
  dep) instead of reading the renderer's privateCfg. This drives the
  migration UX: an existing install with a legacy privateCfg value but
  no main-side path lands on "not configured" and gets re-prompted by
  the picker. A one-cycle "please pick again" is the migration cost; we
  decided against silently promoting the renderer's persisted value
  because it could be tampered with.
- getFilePath returns the *relative* path (no absolute folder prefix).
  The leading-slash strip is preserved so existing call sites that
  prepend '/' still work.
- pickDirectory no longer writes to renderer privateCfg — main owns
  persistence. The dep contract still returns the display string.
- The privateCfg.syncFolderPath field is marked @deprecated; the
  field is kept for migration of older configs and may be removed in a
  later pass once the migration has rolled out.

Renderer
- ElectronFileAdapter forwards the relative path verbatim to the IPC.
- The renderer's LocalFileSyncElectron deps wire pickDirectory and the
  new getMainSyncFolderPath to the bridge.

Tests
- electron/local-file-sync.test.cjs rewritten for the new signatures:
  happy path, traversal escapes, absolute-relativePath rejection,
  sync-folder-equals-userData rejection (grant-file forgery defense),
  PICK persists main-side, GET returns the configured path, TO_FILE_URL
  refuses userData.
- electron/sync-folder-store.test.cjs covers persistence,
  init-before-get throw, null/empty handling, idempotent set.
- The package's local-file-sync-electron.spec.ts is updated for the new
  deps shape and asserts the package no longer writes to the renderer
  credential store.

All 56 electron-side tests pass; 374 sync-providers tests pass.
2026-06-09 22:40:50 +02:00
Johannes Millan
880cfdcaf7
fix(electron): lock in-window navigation to the app's loaded origin (#8230)
* fix(electron): lock in-window navigation to the app's loaded origin

The previous will-navigate handler accepted any URL whose hostname was
'localhost' or '127.0.0.1', which left the privileged main window
reachable by any local web server (e.g. http://127.0.0.1:1337/) — the
preload bridge (window.ea) would have been exposed to whatever page that
server returned. In production the legitimate origin is file:// only, so
there is no excuse for ever navigating to http://localhost in-window.

Compare against the URL the app actually loaded (captured at loadURL
time) instead of a hostname allowlist. http/https requires exact
host+port match; file:// requires the same html pathname; data:/blob:/
javascript: are rejected outright. The same guard is also applied to
will-redirect so a same-origin start cannot be redirected onto an
attacker page mid-navigation, and a defensive did-create-window handler
destroys any unexpected child window (the deny-all setWindowOpenHandler
should make that path unreachable).

Also tightens TO_FILE_URL with the same userData deny used by the other
file-sync IPCs, so a plugin/XSS cannot launder a userData path into a
file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL.

* fix(electron): reject UNC/remote-host file:// in navigation guard

`new URL('file://192.168.1.100/Applications/SP.app/Contents/Resources/index.html').pathname`
equals the local app's pathname, so the previous pathname-only check
considered an attacker-controlled UNC host same-origin and let it load
in the privileged main window. The app's loaded file:// URL is always
local (empty host), so require `target.host === ''` explicitly.

* test(electron): add userinfo-@-trick navigation guard regression

`http://localhost:4200@evil.com/` parses with host=evil.com and
username=localhost — a naive substring or startsWith check would be
fooled. The existing host-equality check already rejects it; lock that
in with a test so a future refactor cannot regress.
2026-06-09 22:34:15 +02:00
johannesjo
c12982f1f9 feat(electron): add sync-path-resolver for renderer-supplied paths
Phase 1 of the fix for #8228, simplified after KISS review.

The earlier draft (commit 5cb83b41a, since reverted) introduced a
multi-grant capability store with opaque grantIds and a feature
discriminator. Review showed the abstraction was overhead: this app
has exactly one user-pickable filesystem location (the sync folder).
Backgrounds will go through copy-to-cache, not folder grants;
plugins do not get folder grants; backups use a main-derived path.
And the opaque grantId added zero marginal security over a featureless
{relativePath} API since an XSS in the renderer would just replay the
id from memory. The security boundary is "main owns the canonical root
and the resolver logic", not the id.

So: drop the store, keep the resolver. resolveSyncPath takes
(syncFolderPath, relativePath, userDataDir) and returns the vetted
absolute path. Layered defenses:

  - syncFolderPath canonicalized every call (folder may have moved
    since startup); missing/inaccessible root denies
  - canonical root must not be userData or live inside it (a user who
    picks userData as their sync folder would otherwise hand the
    renderer authority over settings/grants/db)
  - relativePath may be '' or '.' for LIST/CHECK ops on the root;
    anything else must not be absolute and must not escape
  - leaf symlinks refused (v1 policy; O_NOFOLLOW alternative is a
    follow-up that needs cross-platform care)
  - canonical resolved path (or deepest existing ancestor when the
    leaf doesn't exist yet) must still live inside the canonical
    root — catches symlinks/junctions in intermediate directories
    and case-fold / 8.3-shortname aliases
  - ancestor walk fails CLOSED on EACCES; only ENOENT lets us walk
    up. A permission-restricted ancestor must not silently allow.

Errors are opaque (PathNotAllowedError, no path in message) and the
stack is stripped — IPC handlers further sanitize before returning to
the renderer.

Phase 2 will wire this into FILE_SYNC_SAVE/LOAD/REMOVE/LIST_FILES and
CHECK_DIR_EXISTS, move syncFolderPath ownership from the renderer's
privateCfg to a main-side simple-store key, and add a re-confirm
migration on first launch.

16 unit tests cover the rejection cases from #8228's test plan plus
the EACCES-fail-closed branches that came out of the KISS review.
2026-06-09 22:23:47 +02:00
johannesjo
7b61d34c6e Revert "feat(electron): add capability grant store for filesystem IPC"
This reverts commit 5cb83b41a0.
2026-06-09 22:21:24 +02:00
johannesjo
5cb83b41a0 feat(electron): add capability grant store for filesystem IPC
Lays the groundwork for issue #8228: today the FILE_SYNC_* IPCs accept
any renderer-supplied absolute path outside userData, which means any
plugin or XSS in the renderer can read/write arbitrary user files
(~/.ssh, /etc/passwd, etc.). The goal is to replace string-path
authorization with capability-based grants: the renderer holds only an
opaque grantId, the main process holds the canonical root, and FS ops
take (grantId, relativePath).

This commit adds the main-side primitives but does not wire them into
any IPC yet:

- grant-store: persisted, main-only, feature-scoped grants backed by
  simple-store under a new 'fileGrants' key. Opaque hex ids from
  crypto.randomBytes. Roots are canonicalized at create time, and
  revalidated at startup so a folder that was moved, deleted, or
  swapped for a symlink-elsewhere invalidates the grant cleanly.

- grant-resolver: resolveGrantPath(grantId, relativePath, feature)
  returns the absolute path the IPC handler may touch. Enforces the
  feature scope match, rejects absolute paths and '..' traversal, and
  refuses symlinks. For not-yet-existing leaves it canonicalizes the
  deepest existing ancestor so a directory symlink mid-path cannot
  sneak a write outside the root. Errors are opaque
  (PathNotAllowedError) and never embed the offending path.

Notes on tests: ts-node's transpile-only loader does not downlevel
'for...of map' iteration, so the store uses Array.from(map.entries())
where it iterates the cache. The existing electron *.test.cjs use the
same loader and the same workaround was already implicit in their
patterns.

Phase 2 (switching FILE_SYNC_SAVE/LOAD/REMOVE/LIST_FILES + CHECK_DIR_EXISTS
to grant-based, with re-confirm migration of the existing syncFolderPath)
and Phase 3 (image picker copy-to-cache) follow in separate commits.
2026-06-09 22:13:54 +02:00
Johannes Millan
33111e46ff fix: harden local file URL guard 2026-06-09 19:32:09 +02:00
Johannes Millan
4bf699735a fix(electron): compare host exactly before in-window navigation
will-navigate gated on url.includes('localhost'), so hosts like https://localhost.evil.com (or a 'localhost' substring in the query) were treated as the app's own origin and navigated the main window directly, bypassing the openUrlInBrowser scheme guard. Parse the URL and match the hostname exactly (localhost/127.0.0.1).

No regression for prod: it serves from file:// and routes via hash (withHashLocation), so will-navigate never fires for the app's own origin; only real external links reach this handler.
2026-06-09 19:32:09 +02:00
Johannes Millan
b361f86a91 fix: close remaining no-click NTLM image-load sinks (GHSA-hr87-735w-hfq3)
A sweep for auto-loading subresource sinks fed attacker-controllable
(synced) content found two more no-click NTLM vectors beyond the
markdown/attachment images already gated:

- READ_LOCAL_IMAGE_AS_DATA_URL (electron/local-file-sync.ts): a synced
  theme backgroundImage 'file://host/share/x.png' reaches this main-process
  handler, where fileURLToPath -> fs.stat/readFile on the resulting UNC path
  opens an SMB connection and leaks the NTLM hash — no click, just the theme
  being active. This is the most reliable variant (main-process fs, not a
  Chromium subresource). Guard with isPathSafeToOpen before any fs access.

- note.imgUrl (note.component): a synced note image URL is bound directly
  to <img [src]>/[enlargeImg] and auto-loads on render. Gate at the setter
  so only a safe URL reaches the bindings.

Both reuse the existing isPathSafeToOpen helper; local file://, http(s),
data: and blob: are unaffected. New electron test proves stat/readFile are
never reached for UNC / remote file:// input.

(cherry picked from commit 80a3c20b3f5a3f535f5d04d7fd215ba569493ef8)
2026-06-09 19:07:15 +02:00
Johannes Millan
7f029b1798
fix(plugins): harden nodeExecution grants (#8205)
* fix(plugins): harden node execution grants

* fix(plugins): harden iframe bridge boundaries (#8208)

* fix(plugins): harden iframe bridge boundaries

* fix(plugins): tighten iframe bridge follow-up

* test(plugins): make node-executor electron test hermetic

The new plugin-node-executor test read the real built-in plugin manifest
(src/assets/bundled-plugins/sync-md/manifest.json), which is a build
artifact absent when 'npm run test:electron' runs in CI (before the
frontend/plugin build). Stub the manifest read, scoped to the executor
module via Module._load, so the grant/token/webContents assertions no
longer depend on built plugin assets.

* fix(plugins): allow iframe formatDate & getCurrentLanguage i18n methods

The iframe API allow-list gate added in #8208 only listed a subset of
the i18n methods that master's #8146 exposes to iframe plugins. Without
this, plugin calls to formatDate/getCurrentLanguage (and translate) are
rejected with 'Unknown API method'. Add all three so the merged gate
matches the methods createBoundMethods/createPluginApiScript expose.

* docs(plugins): document node-exec handoff bootstrap-ordering invariant

The one-shot consumePluginNodeExecutionApi() handoff is defended by
construction ordering, not structural isolation: PluginBridgeService must
consume it before any plugin 'new Function' code runs (both share window.ea
in one renderer realm). Document the invariant at the consumption site so a
future lazy-service/early-plugin-load refactor can't silently regress it.
Surfaced by the post-merge security review (latent finding; not currently
exploitable).

* fix(plugins): use static iframe sandbox attribute to avoid NG0910

Binding a security-sensitive iframe attribute (sandbox) via [attr.sandbox]
makes Angular throw RuntimeError NG0910 and tear down the iframe, crashing
the plugin view to the global error screen. This broke the plugin-iframe,
plugin-loading and plugin-lifecycle e2e tests.

Restore the static sandbox attribute (still without allow-same-origin, so
the opaque-origin isolation from #8208 is preserved) and drop the now-unused
iframeSandbox binding/import.
2026-06-09 18:16:41 +02:00
Johannes Millan
c86e369369
fix(security): restrict renderer-supplied filesystem IPC paths (#8223)
* fix(security): canonicalize file-path-guard and restrict FILE_SYNC_* to outside userData

file-path-guard now canonicalizes via fs.realpathSync.native (deepest existing
ancestor) before the path.relative check, so symlinks and case-insensitive / 8.3
filesystem aliases can't bypass containment — this also hardens the existing
backup-dir (GHSA-x937) check, which was purely lexical and bypassable on macOS.

Adds assertPathOutside and applies it to every FILE_SYNC_* handler and
READ_LOCAL_IMAGE_AS_DATA_URL in local-file-sync.ts, so the renderer (untrusted
plugin/XSS) can no longer read/write/delete/list/inline files inside the app's
private dir (userData: settings/grants/db) while user-chosen sync folders still
work.

* fix(security): validate clipboard image basePath, fileName and copy source

ipc-handler-wrapper now uses isPathInsideDir containment (not a bare startsWith,
which accepted a sibling like <userData>-evil) and rejects any fileName that is
not a plain image basename or imageId containing separators/.. — closing the
clipboard grant-file forgery (fileName='simpleSettings'). CLIPBOARD_COPY_IMAGE_FILE
now requires an image-extension source. The supported-image-extension allowlist
is consolidated into a single SUPPORTED_IMAGE_EXTENSIONS in mime-type-mapping.
2026-06-09 18:16:22 +02:00
Johannes Millan
cd86ba2c0d
fix(electron): validate URL scheme before shell.openExternal/openPath (GHSA-hr87-735w-hfq3) (#8210)
* fix(electron): validate URL scheme before shell.openExternal (GHSA-hr87-735w-hfq3)

Task-note Markdown links had their href passed verbatim to shell.openExternal
on click, letting note content (sync, imported backups, issue-provider data)
silently invoke any OS-registered protocol handler — ms-msdt: (Follina),
search-ms:, \\host\share (NTLM hash capture), etc. — with one click and no
prompt. Angular's sanitizer does not help: its SAFE_URL_PATTERN blocks only
javascript:, so these schemes pass.

Add a shared scheme allowlist (http/https/mailto/file) enforced at both layers:

- marked renderer (marked-options-factory): blocked-scheme links render as
  inert <span> text instead of an anchor (covers all note render paths + web).
- Electron main process, both shell.openExternal sinks: the OPEN_EXTERNAL IPC
  handler (system.ts) and openUrlInBrowser (main-window.ts, the
  will-navigate/window-open path used by the fullscreen/archived dialogs).

The allowlist lives in electron/shared-with-frontend so renderer and main agree
on one policy. Blocked links are styled muted/non-interactive with an
explanatory tooltip.

* fix(electron): harden openExternal/openPath against SMB & attribute-injection (GHSA-hr87-735w-hfq3)

Follow-up hardening from multi-agent review of the initial fix:

- file:// SMB bypass: `file:` was allow-listed unconditionally, so
  `file://host/share` / `file:////host` reached shell.openExternal — the same
  NTLM-leak vector the raw `\\host\share` check blocked. Restrict `file:` to the
  canonical local form `file:///<path>`. Gated on the raw string because
  Chromium (renderer) and Node (main) parse file: hosts/paths differently.
- IPC.OPEN_PATH was unguarded: a synced FILE-attachment path of `\\host\share`
  leaked NTLM via shell.openPath. Reject UNC paths (new isUncPath helper).
- Attribute-injection XSS: the marked link renderer interpolated href/title
  raw; marked's angle-bracket link form could inject an event handler that runs
  under disableSanitizer. HTML-escape href/title.
- Remove the now-dead/misleading explicit backslash check and redundant
  toLowerCase (new URL already throws on raw UNC; protocol is spec-lowercase).

Adds cross-engine-verified tests for file:// SMB forms, isUncPath, and the
href quote-injection case.

* fix(electron): block remote file:// in OPEN_PATH too (GHSA-hr87-735w-hfq3)

Re-review found the OPEN_PATH guard was incomplete: isUncPath only rejects raw
slash-prefixed paths, but shell.openPath('file://host/share') resolves to
\\host\share on Windows — the same NTLM leak. A synced FILE-attachment path is
attacker-controllable.

Centralize the path policy in isPathSafeToOpen() (rejects UNC paths and remote
file:// URLs, allows local paths and file:///) so the two sinks can't drift.

* fix(electron): gate image src against remote file:// / UNC (GHSA-hr87-735w-hfq3)

Markdown image src auto-loads on render (no click), so a remote
file://host/share or UNC src silently triggers an SMB connection and
leaks the user's NTLM hash just by viewing a synced/imported note — the
same vector the link/openExternal gate already blocks, but with no user
interaction. CSP (img-src ... file:) does not block it.

Gate image href through isPathSafeToOpen: remote file:// and UNC srcs
render as inert (HTML-escaped) alt text instead of an <img>; local
file://, http(s), data: and blob: images are unaffected.

Also drop a dead eslint-disable directive on ALLOWED_EXTERNAL_URL_SCHEMES
(the name does not trip naming-convention).

* fix(tasks): gate IMG attachment src against remote file:// / UNC (GHSA-hr87-735w-hfq3)

An IMG-type task attachment renders <img [src]="resolvedOriginalPath">,
which auto-loads on render with no click. A synced/imported remote
file://host or UNC path there silently triggers an SMB fetch and leaks
the user's NTLM hash just by opening the task — the same no-click vector
the markdown-image gate already blocks, but via Angular template binding
(Angular's URL sanitizer does not help; SAFE_URL_PATTERN allows file:).

Drop unsafe srcs via isPathSafeToOpen so they never reach the binding;
local file://, http(s), data: and blob: images are unaffected.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-09 17:29:18 +02:00
Symon Baikov
e0be3a1a16
Feat/task widget global shortcut (#7099)
* feat(electron): add global shortcut for task widget toggle

Adds a configurable global shortcut (`globalToggleTaskWidget`) that
shows/hides the task widget without changing the persisted
enabled/disabled preference. The shortcut is a no-op while the task
widget feature is disabled.

When the user reveals the widget via the shortcut while the main window
is visible, a sticky "user-forced visible" flag keeps it up (like
always-show) instead of letting the next focus/show event immediately
hide it. The flag clears when the user toggles the widget off, opens the
app from the widget, or the feature is disabled.

Rebased onto master to drop the now-superseded tray-indicator refactor;
the only main-window.ts change is the additive user-forced-visible gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(electron): handle task widget shortcut races

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:11:16 +02:00
Johannes Millan
b729fd941c
fix(electron): restrict loadBackupData IPC to the backup directory (#8206)
* test(e2e): retry supersync time-estimate dialog until input binds

The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.

* fix(electron): restrict loadBackupData IPC to the backup directory

The BACKUP_LOAD_DATA handler passed the renderer-supplied path straight
to readFileSync with no validation. Because plugin background scripts run
via `new Function` in the renderer, any installed plugin (or an XSS
payload in task content) could read arbitrary files through
window.ea.loadBackupData('/etc/shadow').

Constrain the path to BACKUP_DIR / BACKUP_DIR_WINSTORE via a new
isPathInsideDir guard. It uses path.relative (not a startsWith string
compare) so `..` traversal is collapsed and a name-prefixed sibling
directory (backups vs backups-evil) is not mistaken for a child. The only
legitimate caller already passes paths built from BACKUP_DIR, and
getBackupPath() is display-only, so backup restore is unaffected.

Adds electron/file-path-guard.test.cjs (node --test) covering traversal
escape, prefix-sibling, absolute-outside, and normalize-stays-inside.

Refs GHSA-x937-wf3j-88q3

* docs(electron): note lexical-only containment in file-path-guard

Clarify that isPathInsideDir does string-based containment (no fs.realpath),
so a symlink planted inside the dir is out of scope — it requires local
filesystem write access, outside this guard's renderer-input threat model.
Surfaced during multi-agent review of the GHSA-x937-wf3j-88q3 fix.

* test(electron): load guard via computed path for asar require check

The electron-smoke job runs tools/verify-electron-requires.js over the
packaged app.asar, which flags literal relative require() calls that can't
resolve in the package. The new guard test had require('./file-path-guard.ts'),
but .ts source is excluded from app.asar, so the check failed. Load the module
via a computed path (require(path.resolve(__dirname, ...))) — the same pattern
the other electron *.test.cjs files use; the scanner skips computed requires.
Also reworded the comment, which had reintroduced the literal pattern (the
scanner matches raw text, comments included).
2026-06-09 15:29:06 +02:00
felix bear
1c53656889
fix(jira): honor no_proxy for electron requests #6324 (#8177)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-09 15:00:43 +02:00