Skip to content

Harden WebDAV chat sync - #3769

Open
NewstarDevelop wants to merge 19 commits into
chatboxai:mainfrom
NewstarDevelop:webdav-chat-sync-hardening
Open

Harden WebDAV chat sync#3769
NewstarDevelop wants to merge 19 commits into
chatboxai:mainfrom
NewstarDevelop:webdav-chat-sync-hardening

Conversation

@NewstarDevelop

@NewstarDevelop NewstarDevelop commented Jun 21, 2026

Copy link
Copy Markdown

Summary

  • Add WebDAV chat-history sync with HTTPS-only fixed endpoint validation across renderer, desktop IPC, and mobile transport.
  • Validate decrypted sync snapshots, migrate legacy chat sessions, and limit sync scope to chat sessions/metas only.
  • Preserve local data on same-ID content conflicts by importing remote content as a synced copy, while handling metadata-only updates without creating copies.
  • Address review feedback by merging the remote snapshot before upload, stripping local-only blob references and derived file attachment ids from synced snapshots, and encoding Basic Auth credentials as UTF-8.
  • Redact WebDAV password and sync encryption password from normal Settings exports unless API KEY & License export is selected.
  • Protect WebDAV uploads with conditional writes: create with If-None-Match: *, overwrite with If-Match ETags, retry after 412 conflicts, and refuse unsafe overwrites when an existing remote snapshot has no ETag.

Test Plan

  • git diff --check
  • corepack pnpm vitest run src/renderer/packages/sync/service.test.ts src/renderer/packages/sync/webdav.test.ts
  • corepack pnpm vitest run src/renderer/packages/sync src/renderer/packages/settings-export.test.ts
  • corepack pnpm biome check src/shared/sync-webdav.ts src/renderer/packages/sync/service.ts src/renderer/packages/sync/service.test.ts src/renderer/packages/sync/webdav.test.ts
  • corepack pnpm biome check src/renderer/packages/sync/types.ts src/renderer/packages/sync/snapshot.ts src/renderer/packages/sync/service.ts src/renderer/packages/sync/snapshot.test.ts src/renderer/packages/sync/service.test.ts
  • corepack pnpm biome check src/renderer/packages/settings-export.ts src/renderer/packages/settings-export.test.ts src/renderer/routes/settings/general.tsx
  • corepack pnpm biome check src/renderer/pages/SettingDialog/AdvancedSettingTab.tsx
  • corepack pnpm check

Notes

  • WebDAV sync still intentionally excludes local attachment/blob contents; local-only file/image references are stripped from synced snapshots.

Contributor Agreement

I agree to contribute all code submitted in this PR to the open-source community edition licensed under GPLv3 and the proprietary official edition without compensation.
I grant the official edition development team the rights to freely use, modify, and distribute this code, including for commercial purposes.
I confirm that this code is my original work, or I have obtained the appropriate authorization from the copyright holder to submit this code under these terms.
I understand that the submitted code will be publicly released under the GPLv3 license, and may also be used in the proprietary official edition.

  • I have read and agree with the above statement.

Constrain WebDAV transport to HTTPS-only fixed sync endpoints, validate decrypted snapshots, and keep sync scope limited to chat sessions while preserving local conflict safety.

Constraint: Desktop IPC must not become a generic HTTP proxy and mobile/desktop sync behavior must stay consistent.

Rejected: Allowing renderer-provided WebDAV targets through platform transports | preserves SSRF and redirect bypass risk.

Confidence: high

Scope-risk: moderate

Directive: Keep future sync expansion out of settings, credentials, license, OAuth, and provider-token storage unless a new reviewed snapshot contract is added.

Tested: git diff --check; vitest run src/shared/sync-settings.test.ts src/renderer/packages/sync/*.test.ts; biome lint src/shared/sync-settings.test.ts src/shared/sync-webdav.ts src/renderer/packages/sync; tsc --noEmit

Not-tested: Full app packaging and live third-party WebDAV interoperability matrix
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c29b4e0b-3d16-4da5-9671-25310cc4b7a0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@itxaiohanglover

Copy link
Copy Markdown

Solid implementation! Using Electron's net.fetch for WebDAV requests bypasses CORS issues. The crypto test for encrypt/decrypt envelope is a good addition. Hardening chat sync is important for reliability.

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the hardening work here. The transport boundary is much safer than a generic renderer-driven fetch, but I found two data-integrity issues that should be fixed before merge, plus one credential compatibility issue.

Comment thread src/renderer/packages/sync/service.ts
Comment thread src/renderer/packages/sync/snapshot.ts Outdated
Comment thread src/renderer/packages/sync/webdav.ts

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One process item before this can move forward: the repository PR template includes a Contributor Agreement section, but this PR body does not include the checkbox confirmation.

Please update the PR description to include and check the template confirmation:

[x] I have read and agree with the above statement.

This confirms the submitted code is authorized for both the GPLv3 community edition and the proprietary official edition under the terms in .github/PULL_REQUEST_TEMPLATE.md.

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the latest head (700ef0a). The earlier upload-merge, local-blob-reference, UTF-8 Basic Auth, and Contributor Agreement issues have been addressed. I found two data-loss risks and one conflict-idempotency issue below, so I am keeping this at changes requested.

Comment thread src/renderer/packages/sync/service.ts Outdated
Comment thread src/renderer/packages/sync/local.ts Outdated
Comment thread src/renderer/packages/sync/snapshot.ts Outdated
… conflict IDs

- Restore overwritten sessions on failure instead of deleting everything (P1)
- Route existing-session updates through chatStore's UpdateQueue and
  React Query cache; new sessions use atomic createSessionWithId (P2)
- Use deterministic UUIDv5 for conflict copies to prevent duplicate
  imports across repeated downloads (P3)
- Track session changes as create/update-metadata kinds for precise
  rollback in downloadAndMergeWebDAVSnapshot
…ents duplicate explosion

- SyncSnapshot gains updatedAt (epoch ms); older snapshots fall back to exportedAt
- isRemoteSnapshotStale() compares remote.updatedAt <= lastSyncedAt; when true,
  both upload and download skip merge to prevent re-creating (Synced copy) duplicates
- upload writes precise updatedAt on every PUT; download returns remoteStale flag
- WebDAVSyncDeps adds optional getLastSyncedAt; local.ts wires it from settings

fix(export): unify AdvancedSettingTab export to redact provider secrets instead of delete

- Remove redundant delete providers branch; sanitizeSettingsForExport already
  strips apiKey/accessKey/secretKey/sessionToken per-provider when !includeSecrets
- Without this fix, unchecking 'API KEY & License' would drop all provider configs
  (model, baseURL, etc.) from export

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the latest head (7830ed7). The previous rollback and serialized-session-write issues are addressed, and stable conflict IDs deduplicate an unchanged replay. However, the new timestamp-based stale gate, mutable synced-copy handling, and non-secret export behavior introduce three blocking regressions. There is also unrelated tooling/file-mode noise that should be removed.

Comment thread src/renderer/packages/sync/service.ts Outdated
Comment thread src/renderer/packages/settings-export.ts
Comment thread src/renderer/packages/sync/snapshot.ts Outdated
Comment thread .gitignore Outdated
- Remove .workbuddy/ entry from .gitignore (local preference, not
  upstream material)
- Restore scripts/ralph/ralph.sh executable bit (0755) in git index
…API keys from settings export

- sanitizeProviderForExport now also deletes the entire oauth object
  (accessToken/refreshToken/extra) alongside apiKey/accessKey/secretKey
- Top-level sanitize deletes memorizedManualLicenseKey and
  lastSelectedLicenseByUser (email-to-license-key map)
- Deep-clone extension.webSearch then delete tavilyApiKey, bochaApiKey,
  queritApiKey
- Tests expanded from 2 to 5: nested OAuth, remembered license key, web
  search keys — both !includeSecrets and includeSecrets directions
…dup; preserve edited sync copies

P1-1 (ETag dedup instead of clock comparison):
- Remove SyncSnapshot.updatedAt, snapshotUpdatedAt(), and the entire
  isRemoteSnapshotStale function that compared device clocks
- New isRemoteSnapshotAlreadySeen: exact byte-identity check scoped to the
  WebDAV endpoint (url+username) + ETag. No ETag / no record / endpoint
  mismatch → always merge (safe default)
- New rememberRemoteSnapshot: records PUT response ETag after upload, GET
  ETag after download; stored as sync.lastSeenEndpoint+lastSeenETag
- Result flag renamed remoteStale → remoteUnchanged; UI message reworded
- Tests rewritten for ETag semantics, plus clock-skew regression test and
  endpoint-scoping regression test

P1-3 (don't throw on edited synced copy):
- existingCopy with different content → continue instead of throw. The
  stable conflict ID uniquely identifies the import event; content
  mismatch can only mean the user edited the copy
- Rewrote the throw assertion test, added reviewer-reproduction regression
  test (import copy → append message → replay → succeed, copy unchanged)
…nc rollback AggregateError

- sanitizeSettingsForExport now defensively handles settings.sync /
  settings.extension being absent (export reads raw storage via cast; old
  settings persisted before these fields existed may lack them until the
  next settings save re-normalizes)
- Added regression test: legacy settings without sync/extension do not
  crash; non-secret redaction still applies to provider fields
- Added AggregateError test for download rollback: when the second session
  create fails and the rollback delete also fails, the download returns
  AggregateError containing both errors (chatboxai#3 checklist gap)

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the latest head (159bf842). The previous clock-ordering, mutable synced-copy, requested credential-redaction, and unrelated-file findings are addressed. I found two ETag-state paths that can still drop remote-only conversations, plus remaining credential fields in the non-secret settings export, so this remains changes requested.

Comment thread src/renderer/packages/sync/service.ts Outdated
Comment thread src/renderer/packages/settings-export.ts
Comment thread src/renderer/packages/settings-export.ts
…crets

- Unconditionally strip lastSyncedAt/lastSeenEndpoint/lastSeenETag from
  sync exports so a restored device does not skip its first download merge.
- Strip mineru.apiToken, MCP transport env (stdio) and headers (http) when
  includeSecrets is false.
- Deep-copy documentParser/mineru and mcp.servers/transport before
  redaction to avoid polluting the original settings object.
…napshot

A merged upload writes remote-only sessions into the uploaded snapshot but
never persists them locally. Recording the snapshot ETag as "last seen"
would make every subsequent download report remoteUnchanged, and those
remote-only sessions would never land on this device.

Guard rememberRemoteSnapshot with `if (!mergeResult)` so the next download
re-merges idempotently and persists sessions that were missing locally.
…te envelope params

- service.ts: reject weak (W/) and malformed ETags in
  requireStrongWebDAVSnapshotETag before attempting conditional PUT.
- snapshot.ts: replace localeCompare with code-unit sort so stable
  conflict UUIDs are deterministic across devices with different locales.
- crypto.ts: validate envelope fields (iterations, salt, IV, ciphertext
  length, canonical Base64) before any key derivation, preventing KDF
  parameter injection attacks.
- Add regression tests for all three fixes.

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the current head (ade18596) specifically for maintainability and UI behavior. The latest export/ETag/crypto commits are included in this pass and the previously resolved data-integrity findings remain fixed. However, a real isolated Electron smoke test found that opening General Settings now crashes in WebDAVSyncSection; the remaining comments cover misleading validation/manual-sync semantics and state ownership that should be clarified before shipping.


const WebDAVSyncSection = () => {
const { t } = useTranslation()
const { setSettings, sync } = useSettingsStore((state) => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This selector returns a fresh object on every useStore snapshot and no shallow equality function is supplied. In an isolated Electron run, navigating to #/settings/general consistently throws Maximum update depth exceeded with WebDAVSyncSection at the top of the stack, so the whole Settings page falls into the error boundary. Splitting this into scalar selectors (state.setSettings and state.sync) makes the page render normally. Please fix the selector and add at least a component smoke test for rendering General Settings so service-only tests cannot miss this regression.

disabled={Boolean(runningAction)}
onClick={() =>
runSyncAction('test', async () => {
await testWebDAVConnection(currentSettings(), { platform })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The form presents the sync encryption password as part of the connection configuration, but this action only performs MKCOL/PROPFIND and never downloads or decrypts an existing snapshot. A correct WebDAV password plus a wrong encryption password therefore produces Connection successful, and the user discovers the invalid configuration only on Upload/Download. When a snapshot exists, validate it with GET + decrypt; otherwise rename this to Test WebDAV credentials and clearly state that the encryption password has not been verified.

</Stack>

<Switch
label={t('Enable WebDAV sync')}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] sync.enabled has no sync lifecycle behind it: outside this section it is never read, and here it only disables the two manual action buttons. The label Enable WebDAV sync implies background/automatic synchronization, which the implementation does not provide. Either wire the flag into an actual sync trigger/lifecycle, or remove the switch and present this explicitly as manual WebDAV backup/merge actions.

loading={runningAction === 'upload'}
disabled={Boolean(runningAction) || !sync.enabled}
onClick={() =>
runSyncAction('upload', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Upload Now can remove conversations from the remote snapshot: once an ETag is already seen, the upload intentionally writes the current local set without re-adding locally deleted sessions. The UI presents this with the same light styling as connection testing and gives no preview or warning, even though the single remote file has no version/undo in this feature. Please confirm the replace/update effect before uploading (ideally show local/remote counts), or at minimum explain that local deletions can be propagated to WebDAV.

password: '',
syncPassword: '',
}),
lastSyncedAt: z.string().optional().catch(undefined),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] These fields are device-local runtime state, not user configuration, but placing them in Settings couples the sync engine, settings UI, persistence, and backup sanitizer. That coupling already caused restored exports to inherit another device's ETag and required special-case redaction. Please separate transferable WebDAVSyncConfig (endpoint/credentials) from a dedicated local WebDAVSyncState (lastSeen/lastSyncedAt), preferably keyed by endpoint/account; this removes the export footgun and makes endpoint switching and future sync providers easier to maintain.

…AV transport

- snapshot.ts: attach syncConflictSourceId to conflict copies so they
  can be recognized across devices; skip re-importing a copy whose source
  session already exists locally.
- snapshot.ts: add sessionHasActiveGeneration() check to prevent uploading
  sessions with in-progress streaming responses.
- snapshot.ts: strip runtime-only message state (cancel, generating,
  status, isStreamingMode) from synced snapshots.
- service.ts: reject upload while any session is still generating, with
  a clear error message.
- session.ts: add optional syncConflictSourceId field to SessionSchema.
- mobile_platform.ts: use new @chatbox/capacitor-webdav-http Capacitor
  plugin for Android WebDAV transport.
- Workspace: add packages/* for monorepo; add new capacitor plugin package.
- Regression tests for all changes (provenance, generation detection,
  legacy copy recognition).

@themez themez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the latest WebDAV sync commits. Two additional issues remain in conflict-copy handling.

Comment thread src/renderer/packages/sync/snapshot.ts Outdated
return remoteSession.syncConflictSourceId
}
const fingerprint = sessionContentFingerprint(remoteSession)
return [...localSessionById.keys()].find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Avoid the quadratic legacy-copy scan for ordinary sessions. Every missing remote session without syncConflictSourceId reaches this full scan, including normal UUIDv4 sessions, and mergeRemoteSnapshot runs synchronously on the renderer thread. With disjoint one-message histories, I measured about 1.0s for 500+500 sessions, 4.0s for 1000+1000, and 16.3s for 2000+2000 on Node 22/macOS; mobile will be worse. Please gate legacy inference to IDs that can actually be old conflict copies (these generated IDs are UUIDv5), or use another bounded migration path instead of computing the local/remote Cartesian product.

Comment thread src/renderer/packages/sync/snapshot.ts Outdated
...remoteSessionWithMeta,
id: conflictId,
name: copiedName,
syncConflictSourceId: remoteSession.syncConflictSourceId ?? remoteSession.id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep provenance tied to the immediate source copy. conflictId above is derived from remoteSession.id, but this assignment inherits the root syncConflictSourceId. If the same synced copy is later edited differently on two devices, the nested conflict points to the original root; on the next round the receiving device compares it with that root instead of its identical immediate copy and imports another duplicate. I reproduced this as an unexpected create change. Set the new copy's source to remoteSession.id and add a nested-conflict round-trip regression test.

…updates, credential sanitization

- settings-export: strip customProviders credentials; drop all custom MCP
  servers on secret-free export (transport args/URL/path may embed secrets)
- sync/local: add restoreSyncSessionMetadata with conditional field-by-field
  rollback; updateSyncSessionMetadata returns scoped undo records; track
  sortOrder/createdAt ordering separately
- sync/service: add previewWebDAVUpload to warn before replacing remote
  sessions; testWebDAVConnection now verifies encryption password
- sync/snapshot: track comparableLocalSessionById to avoid false conflicts
  when outgoing copy differs from local (e.g. differing sortOrder)
- general.tsx: remove sync enabled toggle (manual-only); add upload preview
  modal (AdaptiveModal) with conflict count; flush StoreStorage before sync
  actions so main/renderer processes share the same WebDAV URL
- SQLiteSessionMetaStorage.update: selective column SET instead of full-row
  replace — only touches fields explicitly provided in the patch
- SessionMetaStorage (IndexedDB).update: read-then-write in a single
  transaction instead of blind put
- StoreStorage: add flushItem for synchronous persistence
- defaults / settings schema: remove sync.enabled (manual sync only)
@NewstarDevelop
NewstarDevelop force-pushed the webdav-chat-sync-hardening branch from fb392d7 to e9f73c4 Compare July 28, 2026 07:48
…lict detection, partial-import retention

Make WebDAV sync import resilient to crashes and race conditions:

- Remove unused `saveSyncMetas`/`deleteSyncSession` exports and
  `saveMetas`/`deleteSession` dependency slots
- `createSessionWithId` becomes idempotent: retry after a crash repairs
  orphaned data instead of rejecting; uses `normalizePersistedSession`/
  `normalizeImportedMeta` to tolerate JSON/SQLite serialization differences
  when detecting true conflicts
- Concurrent fixed-ID imports serialize: the second caller waits for the
  first, then reconciles against the now-present state
- Failed imports keep created sessions instead of deleting them; retry
  reuses them directly

Co-authored-by: Reasonix <reasonix@mocia.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants