Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0245929
Harden WebDAV chat sync before enabling push
NewstarDevelop Jun 21, 2026
0279b3b
Address WebDAV sync review feedback
NewstarDevelop Jul 5, 2026
d581e2b
Strip local file keys from synced attachment ids
NewstarDevelop Jul 5, 2026
49ebca2
Redact WebDAV sync secrets from settings export
NewstarDevelop Jul 5, 2026
d2a0920
Handle metadata-only WebDAV sync updates
NewstarDevelop Jul 5, 2026
700ef0a
Use conditional WebDAV snapshot uploads
NewstarDevelop Jul 5, 2026
5b28d21
fix(webdav-sync): transactional import, serialized writes, and stable…
NewstarDevelop Jul 21, 2026
7830ed7
fix(sync): add updatedAt to snapshots, skip stale remote merge — prev…
NewstarDevelop Jul 21, 2026
1e62243
chore: revert unrelated .gitignore/ralph.sh noise
NewstarDevelop Jul 22, 2026
842ada9
fix(export): sanitize nested OAuth, memorized license, and webSearch …
NewstarDevelop Jul 22, 2026
efdc1c9
fix(sync): replace wall-clock stale gate with endpoint-scoped ETag de…
NewstarDevelop Jul 22, 2026
159bf84
fix(export): tolerate legacy settings missing sync/extension; test sy…
NewstarDevelop Jul 22, 2026
d90ba9c
fix(export): strip device-local sync state and sanitize mineru/MCP se…
NewstarDevelop Jul 22, 2026
6274c17
fix(sync): skip recording PUT ETag when upload merged unseen remote s…
NewstarDevelop Jul 22, 2026
ade1859
fix(sync): reject weak ETags, locale-independent conflict IDs, valida…
NewstarDevelop Jul 22, 2026
a7ead88
fix(sync): provenance tracking, active-generation guard, Android WebD…
NewstarDevelop Jul 22, 2026
3701b49
chore: add capacitor-webdav-http dist (force-added, excluded by globa…
NewstarDevelop Jul 22, 2026
e9f73c4
fix(sync): hardening — upload preview, scoped undo, selective column …
NewstarDevelop Jul 24, 2026
edb3c87
fix(sync): harden WebDAV import — idempotent retry, parity-aware conf…
NewstarDevelop Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import './legacy-database-migration'
*/

import fs from 'node:fs'
import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, session, shell, Tray } from 'electron'
import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, net, session, shell, Tray } from 'electron'
import electronDebug from 'electron-debug'
import log from 'electron-log/main'
import os from 'os'
import path from 'path'
// @ts-expect-error - source-map-support doesn't have type definitions
import * as sourceMapSupport from 'source-map-support'
import { executeWebDAVRequest, type WebDAVRequest } from 'src/shared/sync-webdav'
import type { ShortcutSetting } from 'src/shared/types'
import * as analystic from './analystic-node'
import { AppUpdater } from './app-updater'
Expand Down Expand Up @@ -763,6 +764,11 @@ ipcMain.handle('ensureAutoLaunch', (event, enable: boolean) => {
return autoLauncher.ensure(enable)
})

ipcMain.handle('webdav:request', async (_event, request: WebDAVRequest) => {
const webdavBaseUrl = getSettings().sync.webdav.url
return executeWebDAVRequest(webdavBaseUrl, request, (input, init) => net.fetch(String(input), init))
})

ipcMain.handle('parseFileLocally', async (event, dataJSON: string) => {
const params: { filePath: string } = JSON.parse(dataJSON)
try {
Expand Down
227 changes: 227 additions & 0 deletions src/renderer/packages/settings-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import * as defaults from '@shared/defaults'
import type { Settings } from '@shared/types'
import { describe, expect, it } from 'vitest'
import { sanitizeSettingsForExport } from './settings-export'

function settingsWithSecrets(): Settings {
return {
...defaults.settings(),
licenseKey: 'license-secret',
licenseDetail: { plan: 'pro' } as unknown as Settings['licenseDetail'],
licenseInstances: { 'license-secret': 'device-1' },
memorizedManualLicenseKey: 'memorized-license-secret',
lastSelectedLicenseByUser: { 'alice@example.com': 'selected-license-secret' },
providers: {
openai: {
apiKey: 'sk-secret',
accessKey: 'access-secret',
secretKey: 'secret-key',
sessionToken: 'session-token',
apiHost: 'https://api.example.com',
oauth: {
accessToken: 'oauth-access-secret',
refreshToken: 'oauth-refresh-secret',
expiresAt: 1893456000000,
},
activeAuthMode: 'oauth',
},
},
sync: {
enabled: true,
provider: 'webdav',
webdav: {
url: 'https://dav.example.com/files/me/',
username: 'alice',
password: 'dav-secret',
syncPassword: 'sync-secret',
},
lastSyncedAt: '2026-07-05T00:00:00.000Z',
lastSeenEndpoint: 'https://dav.example.com/files/me/\nalice',
lastSeenETag: '"seen-etag"',
},
extension: {
...defaults.settings().extension,
webSearch: {
...defaults.settings().extension.webSearch,
provider: 'tavily',
tavilyApiKey: 'tavily-secret',
bochaApiKey: 'bocha-secret',
queritApiKey: 'querit-secret',
},
documentParser: {
type: 'mineru',
mineru: { apiToken: 'mineru-secret' },
},
},
mcp: {
servers: [
{
id: 'local-fs',
name: 'Local FS',
enabled: true,
transport: {
type: 'stdio',
command: 'npx',
args: ['srv'],
env: { GITHUB_TOKEN: 'ghp-secret' },
},
},
{
id: 'remote-http',
name: 'Remote HTTP',
enabled: true,
transport: {
type: 'http',
url: 'https://mcp.example.com/',
headers: { Authorization: 'Bearer secret' },
},
},
],
enabledBuiltinServers: [],
},
}
}

describe('sanitizeSettingsForExport', () => {
it('removes WebDAV and provider secrets when key export is not selected', () => {
const settings = settingsWithSecrets()
const sanitized = sanitizeSettingsForExport(settings, false)

expect(sanitized.licenseKey).toBeUndefined()
expect(sanitized.licenseDetail).toBeUndefined()
expect(sanitized.licenseInstances).toBeUndefined()
expect(sanitized.providers?.openai).toEqual({
apiHost: 'https://api.example.com',
activeAuthMode: 'oauth',
})
expect(sanitized.sync.webdav).toEqual({
url: 'https://dav.example.com/files/me/',
username: 'alice',
password: '',
syncPassword: '',
})
expect(settings.sync.webdav.password).toBe('dav-secret')
expect(settings.sync.webdav.syncPassword).toBe('sync-secret')
})

it('removes nested OAuth credentials when key export is not selected', () => {
const settings = settingsWithSecrets()
const sanitized = sanitizeSettingsForExport(settings, false)

expect(sanitized.providers?.openai?.oauth).toBeUndefined()
// The original settings object must keep its credentials untouched.
expect(settings.providers?.openai?.oauth?.accessToken).toBe('oauth-access-secret')
expect(settings.providers?.openai?.oauth?.refreshToken).toBe('oauth-refresh-secret')
})

it('removes remembered and per-account license keys when key export is not selected', () => {
const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), false)

expect(sanitized.memorizedManualLicenseKey).toBeUndefined()
expect(sanitized.lastSelectedLicenseByUser).toBeUndefined()
})

it('removes web search API keys when key export is not selected', () => {
const settings = settingsWithSecrets()
const sanitized = sanitizeSettingsForExport(settings, false)

expect(sanitized.extension.webSearch.provider).toBe('tavily')
expect(sanitized.extension.webSearch.tavilyApiKey).toBeUndefined()
expect(sanitized.extension.webSearch.bochaApiKey).toBeUndefined()
expect(sanitized.extension.webSearch.queritApiKey).toBeUndefined()
// The original settings object must keep its keys untouched.
expect(settings.extension.webSearch.tavilyApiKey).toBe('tavily-secret')
expect(settings.extension.webSearch.bochaApiKey).toBe('bocha-secret')
expect(settings.extension.webSearch.queritApiKey).toBe('querit-secret')
})

it('removes device-local sync state from every export', () => {
const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true)
const withoutSecrets = sanitizeSettingsForExport(settingsWithSecrets(), false)

// lastSeen is this device's sync identity, not user configuration: a
// restored device inheriting it would skip its first download merge
// against the unchanged remote snapshot. Strip it regardless of the
// includeSecrets flag.
for (const sanitized of [withSecrets, withoutSecrets]) {
expect(sanitized.sync.lastSyncedAt).toBeUndefined()
expect(sanitized.sync.lastSeenEndpoint).toBeUndefined()
expect(sanitized.sync.lastSeenETag).toBeUndefined()
}
// Credentials still follow the includeSecrets flag; only the device-local
// state is always stripped.
expect(withSecrets.sync.webdav.password).toBe('dav-secret')
expect(withoutSecrets.sync.webdav.password).toBe('')
})

it('removes the MinerU API token when key export is not selected', () => {
const settings = settingsWithSecrets()
const sanitized = sanitizeSettingsForExport(settings, false)

expect(sanitized.extension.documentParser?.type).toBe('mineru')
expect(sanitized.extension.documentParser?.mineru?.apiToken).toBe('')
expect(sanitizeSettingsForExport(settingsWithSecrets(), true).extension.documentParser?.mineru?.apiToken).toBe(
'mineru-secret'
)
// The original settings object must keep its token untouched.
expect(settings.extension.documentParser?.mineru?.apiToken).toBe('mineru-secret')
})

it('removes MCP transport env and headers when key export is not selected', () => {
const settings = settingsWithSecrets()
const sanitized = sanitizeSettingsForExport(settings, false)

const [stdioServer, httpServer] = sanitized.mcp.servers
expect(stdioServer.transport).toEqual({ type: 'stdio', command: 'npx', args: ['srv'] })
expect(httpServer.transport).toEqual({ type: 'http', url: 'https://mcp.example.com/' })
// Transport credentials survive when key export is selected.
const withSecrets = sanitizeSettingsForExport(settingsWithSecrets(), true)
expect(withSecrets.mcp.servers[0].transport).toMatchObject({ env: { GITHUB_TOKEN: 'ghp-secret' } })
expect(withSecrets.mcp.servers[1].transport).toMatchObject({ headers: { Authorization: 'Bearer secret' } })
// The original settings object must keep its credentials untouched.
expect(settings.mcp.servers[0].transport).toMatchObject({ env: { GITHUB_TOKEN: 'ghp-secret' } })
expect(settings.mcp.servers[1].transport).toMatchObject({ headers: { Authorization: 'Bearer secret' } })
})

it('keeps WebDAV and provider secrets when key export is selected', () => {
const sanitized = sanitizeSettingsForExport(settingsWithSecrets(), true)

expect(sanitized.licenseKey).toBe('license-secret')
expect(sanitized.memorizedManualLicenseKey).toBe('memorized-license-secret')
expect(sanitized.lastSelectedLicenseByUser).toEqual({ 'alice@example.com': 'selected-license-secret' })
expect(sanitized.providers?.openai?.apiKey).toBe('sk-secret')
expect(sanitized.providers?.openai?.oauth?.accessToken).toBe('oauth-access-secret')
expect(sanitized.providers?.openai?.oauth?.refreshToken).toBe('oauth-refresh-secret')
expect(sanitized.extension.webSearch.tavilyApiKey).toBe('tavily-secret')
expect(sanitized.extension.webSearch.bochaApiKey).toBe('bocha-secret')
expect(sanitized.extension.webSearch.queritApiKey).toBe('querit-secret')
expect(sanitized.sync.webdav.password).toBe('dav-secret')
expect(sanitized.sync.webdav.syncPassword).toBe('sync-secret')
expect(sanitized.licenseDetail).toBeUndefined()
expect(sanitized.licenseInstances).toBeUndefined()
})

it('does not crash on settings persisted before sync and extension fields existed', () => {
// Raw storage from older app versions lacks these objects entirely; the
// export path casts without schema parsing, so sanitize must cope.
const legacy = {
...defaults.settings(),
licenseKey: 'license-secret',
providers: {
openai: {
apiKey: 'sk-secret',
apiHost: 'https://api.example.com',
},
},
sync: undefined,
extension: undefined,
} as unknown as Settings

const sanitized = sanitizeSettingsForExport(legacy, false)

expect(sanitized.licenseKey).toBeUndefined()
expect(sanitized.providers?.openai).toEqual({ apiHost: 'https://api.example.com' })
expect(sanitized.sync).toBeUndefined()
expect(sanitized.extension).toBeUndefined()
})
})
103 changes: 103 additions & 0 deletions src/renderer/packages/settings-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { ProviderSettings, Settings } from '@shared/types'

function sanitizeProviderForExport(provider: ProviderSettings): ProviderSettings {
const cleanedProvider = { ...provider }
delete cleanedProvider.apiKey
delete cleanedProvider.accessKey
delete cleanedProvider.secretKey
delete cleanedProvider.sessionToken
Comment thread
themez marked this conversation as resolved.
// Nested OAuth credentials (accessToken / refreshToken / extra tokens) are
// just as sensitive as API keys; the whole object must not be exported.
delete cleanedProvider.oauth
return cleanedProvider
}

export function sanitizeSettingsForExport(settings: Settings, includeSecrets: boolean): Settings {
// The export path reads raw storage via a type cast, and settings persisted
// before the sync/extension fields existed are not re-normalized until the
// next settings save — so both objects can be absent at runtime despite
// being required in the schema. Spread and redact defensively.
const cleanedSettings: Settings = {
...settings,
licenseDetail: undefined,
licenseInstances: undefined,
providers: settings.providers ? { ...settings.providers } : settings.providers,
extension: settings.extension
? {
...settings.extension,
webSearch: {
...settings.extension.webSearch,
},
documentParser: settings.extension.documentParser
? {
...settings.extension.documentParser,
mineru: settings.extension.documentParser.mineru
? { ...settings.extension.documentParser.mineru }
: undefined,
}
: settings.extension.documentParser,
}
: settings.extension,
sync: settings.sync
? {
...settings.sync,
Comment thread
themez marked this conversation as resolved.
// Device-local sync state (which snapshot this device last saw, and
// when it synced) must not travel with an export: a restored device
// would inherit the old device's lastSeen identity and skip its
// first download merge against the unchanged remote snapshot.
lastSyncedAt: undefined,
lastSeenEndpoint: undefined,
lastSeenETag: undefined,
webdav: {
...settings.sync.webdav,
},
}
: settings.sync,
mcp: settings.mcp
? {
...settings.mcp,
servers: Array.isArray(settings.mcp.servers)
? settings.mcp.servers.map((server) => ({ ...server, transport: { ...server.transport } }))
: settings.mcp.servers,
}
: settings.mcp,
}

if (!includeSecrets) {
Comment thread
themez marked this conversation as resolved.
delete cleanedSettings.licenseKey
// License keys remembered for the UI or selected per account are still
// credentials even though the active licenseKey lives elsewhere.
delete cleanedSettings.memorizedManualLicenseKey
delete cleanedSettings.lastSelectedLicenseByUser
if (cleanedSettings.providers) {
cleanedSettings.providers = Object.fromEntries(
Object.entries(cleanedSettings.providers).map(([id, provider]) => [id, sanitizeProviderForExport(provider)])
) as Settings['providers']
}
if (cleanedSettings.extension?.webSearch) {
delete cleanedSettings.extension.webSearch.tavilyApiKey
delete cleanedSettings.extension.webSearch.bochaApiKey
delete cleanedSettings.extension.webSearch.queritApiKey
}
if (cleanedSettings.sync?.webdav) {
cleanedSettings.sync.webdav.password = ''
cleanedSettings.sync.webdav.syncPassword = ''
}
if (cleanedSettings.extension?.documentParser?.mineru) {
cleanedSettings.extension.documentParser.mineru.apiToken = ''
}
// MCP transports routinely carry credentials — stdio env vars (API tokens)
// and HTTP headers (Authorization) must not leave the device in an export.
if (cleanedSettings.mcp?.servers) {
for (const server of cleanedSettings.mcp.servers) {
if (server.transport.type === 'stdio') {
delete server.transport.env
} else {
delete server.transport.headers
}
}
}
}

return cleanedSettings
}
Loading