Commit graph

11 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
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
9b58b883af refactor(electron): pick-time userData rejection + shared store key 2026-06-10 17:04:07 +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
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