diff --git a/electron/main/fileReader.ts b/electron/main/fileReader.ts index adccf1782..69c596985 100644 --- a/electron/main/fileReader.ts +++ b/electron/main/fileReader.ts @@ -23,6 +23,15 @@ import * as unzipper from 'unzipper'; import { URL } from 'url'; import { parseStringPromise } from 'xml2js'; +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + interface FileInfo { path: string; name: string; @@ -225,7 +234,7 @@ export class FileReader { const cellValue = cell ? this.getCellValue(cell, sharedStrings) : ''; - html += `${cellValue}`; + html += `${escapeHtml(String(cellValue))}`; } html += ''; @@ -343,7 +352,7 @@ export class FileReader { for (const run of runs) { const text = run?.['a:t']?.[0]; if (text) { - html += `
  • ${text}
  • `; + html += `
  • ${escapeHtml(String(text))}
  • `; } } } @@ -378,7 +387,7 @@ export class FileReader { // Header row html += ''; headers.forEach((header) => { - html += `${header}`; + html += `${escapeHtml(String(header))}`; }); html += ''; @@ -387,7 +396,7 @@ export class FileReader { result.data.forEach((row: any) => { html += ''; headers.forEach((header) => { - html += `${row[header] || ''}`; + html += `${escapeHtml(String(row[header] || ''))}`; }); html += ''; }); diff --git a/electron/main/index.ts b/electron/main/index.ts index dd9623379..757d2d108 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -262,13 +262,13 @@ function processProtocolUrl(url: string) { log.info('oauth'); const provider = urlObj.searchParams.get('provider'); const code = urlObj.searchParams.get('code'); - log.info('protocol oauth', provider, code); + log.info('protocol oauth', provider, '[REDACTED]'); win.webContents.send('oauth-authorized', { provider, code }); return; } if (code) { - log.error('protocol code:', code); + log.info('protocol code received, length:', code?.length); win.webContents.send('auth-code-received', code); } @@ -853,7 +853,7 @@ function registerIpcHandlers() { .stat(filePath.replace(/\/$/, '')) .catch(() => null); if (stats && stats.isDirectory()) { - shell.openPath(filePath); + shell.showItemInFolder(filePath + path.sep); } else { shell.showItemInFolder(filePath); } diff --git a/electron/main/update.ts b/electron/main/update.ts index 5239725e8..5699d25cd 100644 --- a/electron/main/update.ts +++ b/electron/main/update.ts @@ -24,7 +24,7 @@ const { autoUpdater } = createRequire(import.meta.url)('electron-updater'); export function update(win: Electron.BrowserWindow) { // When set to false, the update download will be triggered through the API - autoUpdater.verifyUpdateCodeSignature = false; + autoUpdater.verifyUpdateCodeSignature = true; autoUpdater.autoDownload = false; autoUpdater.disableWebInstaller = false; autoUpdater.allowDowngrade = false; @@ -147,10 +147,12 @@ function startDownload( callback: (error: Error | null, info: ProgressInfo | null) => void, complete: (event: UpdateDownloadedEvent) => void ) { + autoUpdater.removeAllListeners('download-progress'); + autoUpdater.removeAllListeners('update-downloaded'); autoUpdater.on('download-progress', (info: ProgressInfo) => callback(null, info) ); - autoUpdater.on('error', (error: Error) => callback(error, null)); - autoUpdater.on('update-downloaded', complete); + autoUpdater.once('error', (error: Error) => callback(error, null)); + autoUpdater.once('update-downloaded', complete); autoUpdater.downloadUpdate(); } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 58254b8a1..496b7d36b 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -15,6 +15,7 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; +import DOMPurify from 'dompurify'; import { CircleAlert } from 'lucide-react'; import { Button } from './button'; import { TooltipSimple } from './tooltip'; @@ -207,9 +208,11 @@ const Input = React.forwardRef( : 'text-text-label' )} dangerouslySetInnerHTML={{ - __html: note.replace( - /(https?:\/\/[^\s]+)/g, - '$1' + __html: DOMPurify.sanitize( + note.replace( + /(https?:\/\/[^\s]+)/g, + '$1' + ) ), }} /> diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index a5b1960d5..51d97d288 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -15,6 +15,7 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; +import DOMPurify from 'dompurify'; import { CircleAlert } from 'lucide-react'; import { Button } from './button'; import { TooltipSimple } from './tooltip'; @@ -257,9 +258,11 @@ const Textarea = React.forwardRef( : 'text-text-label' )} dangerouslySetInnerHTML={{ - __html: note.replace( - /(https?:\/\/[^\s]+)/g, - '$1' + __html: DOMPurify.sanitize( + note.replace( + /(https?:\/\/[^\s]+)/g, + '$1' + ) ), }} /> diff --git a/src/lib/index.ts b/src/lib/index.ts index 55ea93e08..e08f0b4d7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -33,9 +33,9 @@ export function getProxyBaseURL() { } export function generateUniqueId(): string { - const timestamp = Date.now(); - const random = Math.floor(Math.random() * 10000); - return `${timestamp}-${random}`; + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } export function debounce void>( @@ -69,9 +69,8 @@ export function capitalizeFirstLetter(input: string): string { export function hasStackKeys() { return ( - import.meta.env.VITE_STACK_PROJECT_ID && - import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY && - import.meta.env.VITE_STACK_SECRET_SERVER_KEY + !!import.meta.env.VITE_STACK_PROJECT_ID && + !!import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY ); } diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index e3764a0c6..991d87afe 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -296,11 +296,15 @@ const chatStore = (initial?: Partial) => task.status === TaskStatus.COMPLETED || task.status === TaskStatus.FAILED ).length; - const taskProgress = ( - ((finishedTask || 0) / (taskRunning?.length || 0)) * - 100 - ).toFixed(2); - setProgressValue(activeTaskId as string, Number(taskProgress)); + const denominator = taskRunning?.length ?? 0; + const taskProgress = + denominator === 0 + ? 0 + : ((finishedTask || 0) / denominator) * 100; + setProgressValue( + activeTaskId as string, + Number(taskProgress.toFixed(2)) + ); }, removeTask(taskId: string) { // Clean up any pending auto-confirm timers when removing a task diff --git a/test/unit/electron/fileReader.test.ts b/test/unit/electron/fileReader.test.ts new file mode 100644 index 000000000..5b9592c8d --- /dev/null +++ b/test/unit/electron/fileReader.test.ts @@ -0,0 +1,67 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Tests for the escapeHtml utility added to electron/main/fileReader.ts. + * + * Because fileReader.ts imports Electron-only modules (BrowserWindow, app) + * that are unavailable in the jsdom vitest environment, we replicate the + * pure function here and verify its behaviour independently. + */ +import { describe, expect, it } from 'vitest'; + +// Replicate the escapeHtml function as defined in electron/main/fileReader.ts +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +describe('escapeHtml (fileReader)', () => { + it('escapes angle brackets', () => { + expect(escapeHtml('')).toBe( + '<script>alert(1)</script>' + ); + }); + + it('escapes ampersands', () => { + expect(escapeHtml('a & b')).toBe('a & b'); + }); + + it('escapes double quotes', () => { + expect(escapeHtml('"hello"')).toBe('"hello"'); + }); + + it('escapes single quotes', () => { + expect(escapeHtml("it's")).toBe('it's'); + }); + + it('handles strings with no special characters', () => { + expect(escapeHtml('hello world')).toBe('hello world'); + }); + + it('handles empty string', () => { + expect(escapeHtml('')).toBe(''); + }); + + it('escapes all special characters in one string', () => { + const input = `
    `; + const expected = + '<div class="test" data-name='foo & bar'>'; + expect(escapeHtml(input)).toBe(expected); + }); +}); diff --git a/test/unit/lib/securityFixes.test.ts b/test/unit/lib/securityFixes.test.ts new file mode 100644 index 000000000..93e7d8f87 --- /dev/null +++ b/test/unit/lib/securityFixes.test.ts @@ -0,0 +1,43 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { generateUniqueId, hasStackKeys } from '@/lib/index'; +import { describe, expect, it, vi } from 'vitest'; + +describe('generateUniqueId', () => { + it('returns a 32-character hex string', () => { + const id = generateUniqueId(); + expect(id).toMatch(/^[0-9a-f]{32}$/); + }); + + it('produces unique values on successive calls', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateUniqueId())); + expect(ids.size).toBe(100); + }); + + it('uses crypto.getRandomValues instead of Math.random', () => { + const spy = vi.spyOn(crypto, 'getRandomValues'); + generateUniqueId(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe('hasStackKeys', () => { + it('does not reference VITE_STACK_SECRET_SERVER_KEY', () => { + // Read the source of hasStackKeys to ensure the secret key is removed + const fnSource = hasStackKeys.toString(); + expect(fnSource).not.toContain('VITE_STACK_SECRET_SERVER_KEY'); + }); +}); diff --git a/test/unit/store/chatStore-divisionByZero.test.ts b/test/unit/store/chatStore-divisionByZero.test.ts new file mode 100644 index 000000000..c1727c5d1 --- /dev/null +++ b/test/unit/store/chatStore-divisionByZero.test.ts @@ -0,0 +1,63 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Test that task progress calculation guards against division by zero. + * + * This test verifies the logic pattern used in chatStore.ts where + * taskProgress is calculated as (finishedTask / taskRunning.length) * 100. + * When taskRunning is empty, the denominator is 0 and would produce NaN + * or Infinity without the guard. + */ +import { describe, expect, it } from 'vitest'; + +function calculateTaskProgress( + finishedTask: number | undefined, + taskRunningLength: number | undefined +): number { + const denominator = taskRunningLength ?? 0; + const taskProgress = + denominator === 0 + ? 0 + : ((finishedTask || 0) / denominator) * 100; + return Number(taskProgress.toFixed(2)); +} + +describe('task progress division-by-zero guard', () => { + it('returns 0 when taskRunning is empty (denominator = 0)', () => { + expect(calculateTaskProgress(0, 0)).toBe(0); + }); + + it('returns 0 when taskRunning is undefined', () => { + expect(calculateTaskProgress(5, undefined)).toBe(0); + }); + + it('calculates correct percentage for valid inputs', () => { + expect(calculateTaskProgress(3, 10)).toBe(30); + }); + + it('returns 0 when finishedTask is undefined and denominator is 0', () => { + expect(calculateTaskProgress(undefined, 0)).toBe(0); + }); + + it('handles 100% completion', () => { + expect(calculateTaskProgress(5, 5)).toBe(100); + }); + + it('never returns NaN or Infinity', () => { + const result = calculateTaskProgress(0, 0); + expect(Number.isFinite(result)).toBe(true); + expect(Number.isNaN(result)).toBe(false); + }); +});