Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 13 additions & 4 deletions electron/main/fileReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

interface FileInfo {
path: string;
name: string;
Expand Down Expand Up @@ -225,7 +234,7 @@ export class FileReader {
const cellValue = cell
? this.getCellValue(cell, sharedStrings)
: '';
html += `<td>${cellValue}</td>`;
html += `<td>${escapeHtml(String(cellValue))}</td>`;
}

html += '</tr>';
Expand Down Expand Up @@ -343,7 +352,7 @@ export class FileReader {
for (const run of runs) {
const text = run?.['a:t']?.[0];
if (text) {
html += `<li>${text}</li>`;
html += `<li>${escapeHtml(String(text))}</li>`;
}
}
}
Expand Down Expand Up @@ -378,7 +387,7 @@ export class FileReader {
// Header row
html += '<thead><tr style="background-color: #f5f5f5;">';
headers.forEach((header) => {
html += `<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">${header}</th>`;
html += `<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">${escapeHtml(String(header))}</th>`;
});
html += '</tr></thead>';

Expand All @@ -387,7 +396,7 @@ export class FileReader {
result.data.forEach((row: any) => {
html += '<tr>';
headers.forEach((header) => {
html += `<td style="border: 1px solid #ddd; padding: 8px;">${row[header] || ''}</td>`;
html += `<td style="border: 1px solid #ddd; padding: 8px;">${escapeHtml(String(row[header] || ''))}</td>`;
});
html += '</tr>';
});
Expand Down
6 changes: 3 additions & 3 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 5 additions & 3 deletions electron/main/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
9 changes: 6 additions & 3 deletions src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -207,9 +208,11 @@ const Input = React.forwardRef<HTMLInputElement, BaseInputProps>(
: 'text-text-label'
)}
dangerouslySetInnerHTML={{
__html: note.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-text-information hover:opacity-70 cursor-pointer transition-opacity duration-200">$1</a>'
__html: DOMPurify.sanitize(
note.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-text-information hover:opacity-70 cursor-pointer transition-opacity duration-200">$1</a>'
)
),
}}
/>
Expand Down
9 changes: 6 additions & 3 deletions src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -257,9 +258,11 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, BaseTextareaProps>(
: 'text-text-label'
)}
dangerouslySetInnerHTML={{
__html: note.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-text-information hover:opacity-70 cursor-pointer transition-opacity duration-200">$1</a>'
__html: DOMPurify.sanitize(
note.replace(
/(https?:\/\/[^\s]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="underline text-text-information hover:opacity-70 cursor-pointer transition-opacity duration-200">$1</a>'
)
),
}}
/>
Expand Down
11 changes: 5 additions & 6 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends (...args: any[]) => void>(
Expand Down Expand Up @@ -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
);
}

Expand Down
14 changes: 9 additions & 5 deletions src/store/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,15 @@ const chatStore = (initial?: Partial<ChatStore>) =>
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
Expand Down
67 changes: 67 additions & 0 deletions test/unit/electron/fileReader.test.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

describe('escapeHtml (fileReader)', () => {
it('escapes angle brackets', () => {
expect(escapeHtml('<script>alert(1)</script>')).toBe(
'&lt;script&gt;alert(1)&lt;/script&gt;'
);
});

it('escapes ampersands', () => {
expect(escapeHtml('a & b')).toBe('a &amp; b');
});

it('escapes double quotes', () => {
expect(escapeHtml('"hello"')).toBe('&quot;hello&quot;');
});

it('escapes single quotes', () => {
expect(escapeHtml("it's")).toBe('it&#039;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 = `<div class="test" data-name='foo & bar'>`;
const expected =
'&lt;div class=&quot;test&quot; data-name=&#039;foo &amp; bar&#039;&gt;';
expect(escapeHtml(input)).toBe(expected);
});
});
43 changes: 43 additions & 0 deletions test/unit/lib/securityFixes.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
63 changes: 63 additions & 0 deletions test/unit/store/chatStore-divisionByZero.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});