Skip to content
Merged
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
64 changes: 42 additions & 22 deletions electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,24 +304,22 @@ function processQueuedProtocolUrls() {

// ==================== single instance lock ====================
const setupSingleInstanceLock = () => {
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
log.info('no-lock');
app.quit();
} else {
app.on('second-instance', (event, argv) => {
log.info('second-instance', argv);
const url = argv.find((arg) => arg.startsWith('eigent://'));
if (url) handleProtocolUrl(url);
if (win) win.show();
});
// The lock is already acquired at module level (requestSingleInstanceLock
// above). Calling it again here would release and re-acquire the lock,
// creating a window where a second instance could start. We only need
// to register the event handlers.
app.on('second-instance', (event, argv) => {
log.info('second-instance', argv);
const url = argv.find((arg) => arg.startsWith('eigent://'));
if (url) handleProtocolUrl(url);
if (win) win.show();
});

app.on('open-url', (event, url) => {
log.info('open-url');
event.preventDefault();
handleProtocolUrl(url);
});
}
app.on('open-url', (event, url) => {
log.info('open-url');
event.preventDefault();
handleProtocolUrl(url);
});
};

// ==================== initialize config ====================
Expand Down Expand Up @@ -443,9 +441,7 @@ function registerIpcHandlers() {
try {
const { spawn } = await import('child_process');

// Add --host parameter
const commandWithHost = `${command} --debug --host dev.eigent.ai/api/oauth/notion/callback?code=1`;
// const commandWithHost = `${command}`;
const commandWithHost = command;

log.info(' start execute command:', commandWithHost);

Expand Down Expand Up @@ -2869,10 +2865,34 @@ app.whenReady().then(async () => {
// Register protocol handler for both default session and main window session
const protocolHandler = async (request: Request) => {
const url = decodeURIComponent(request.url.replace('localfile://', ''));
const filePath = path.normalize(url);
const filePath = path.resolve(path.normalize(url));

log.info(`[PROTOCOL] Handling localfile request: ${request.url}`);
log.info(`[PROTOCOL] Decoded path: ${filePath}`);
log.info(`[PROTOCOL] Resolved path: ${filePath}`);

// Security: Restrict file access to allowed directories only.
// Without this check, path traversal (e.g. /../../../etc/passwd)
// would allow reading arbitrary files on the filesystem.
const allowedBases = [
os.homedir(),
app.getPath('userData'),
app.getPath('temp'),
];

const isPathAllowed = allowedBases.some((base) => {
const resolvedBase = path.resolve(base);
return (
filePath === resolvedBase ||
filePath.startsWith(resolvedBase + path.sep)
);
});

if (!isPathAllowed) {
log.error(
`[PROTOCOL] Security: Blocked access to path outside allowed directories: ${filePath}`
);
return new Response('Forbidden', { status: 403 });
}

try {
// Check if file exists
Expand Down
3 changes: 2 additions & 1 deletion server/app/controller/mcp/proxy_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import logging
from typing import Any, cast
from urllib.parse import quote_plus

import requests
from exa_py import Exa
Expand Down Expand Up @@ -129,7 +130,7 @@ def google_search(query: str, search_type: str = "web", key: Key = Depends(key_m
# Doc: https://developers.google.com/custom-search/v1/using_rest
base_url = (
f"https://www.googleapis.com/customsearch/v1?"
f"key={GOOGLE_API_KEY}&cx={SEARCH_ENGINE_ID}&q={query}&start="
f"key={GOOGLE_API_KEY}&cx={SEARCH_ENGINE_ID}&q={quote_plus(query)}&start="
f"{start_page_idx}&lr={search_language}&num={num_result_pages}"
)

Expand Down
35 changes: 32 additions & 3 deletions server/app/model/chat/chat_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,46 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import logging
import os
import secrets

from itsdangerous import URLSafeTimedSerializer
from pydantic import BaseModel

logger = logging.getLogger(__name__)


def _get_secret_key() -> str:
"""Return the share-token signing key.

Falls back to a random ephemeral key when the environment variable
is not set. A hardcoded default must never be used because the
source code is public and anyone could forge valid share tokens.
"""
key = os.getenv("CHAT_SHARE_SECRET_KEY")
if key:
return key
logger.warning(
"CHAT_SHARE_SECRET_KEY not set — using a random ephemeral key. "
"Share links will not survive server restarts. "
"Set the CHAT_SHARE_SECRET_KEY environment variable for persistence."
)
return secrets.token_urlsafe(32)


def _get_salt() -> str:
salt = os.getenv("CHAT_SHARE_SALT")
if salt:
return salt
return secrets.token_urlsafe(8)


class ChatShare:
SECRET_KEY = os.getenv("CHAT_SHARE_SECRET_KEY", "EGB1WRC9xMUVgNoIPH8tLw")
SALT = os.getenv("CHAT_SHARE_SALT", "r4U2M")
SECRET_KEY = _get_secret_key()
SALT = _get_salt()
# Set expiration to 1 day
EXPIRATION_SECONDS = int(os.getenv("CHAT_SHARE_EXPIRATION_SECONDS", 60 * 60 * 24))
EXPIRATION_SECONDS = int(os.getenv("CHAT_SHARE_EXPIRATION_SECONDS", str(60 * 60 * 24)))

@classmethod
def generate_token(cls, task_id: str) -> str:
Expand Down
Empty file added server/tests/__init__.py
Empty file.
93 changes: 93 additions & 0 deletions server/tests/test_chat_share.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# ========= 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 os
from unittest.mock import patch

import pytest


class TestChatShareSecretKey:
"""Tests for ChatShare secret key generation.

Validates that the hardcoded default secret key has been replaced
with secure random generation when environment variables are not set.
"""

def test_get_secret_key_returns_env_var_when_set(self):
"""_get_secret_key should return the environment variable value."""
with patch.dict(os.environ, {"CHAT_SHARE_SECRET_KEY": "my-custom-key"}):
from app.model.chat.chat_share import _get_secret_key

assert _get_secret_key() == "my-custom-key"

def test_get_secret_key_generates_random_when_env_not_set(self):
"""_get_secret_key should generate a random key when env var is absent."""
env = os.environ.copy()
env.pop("CHAT_SHARE_SECRET_KEY", None)
with patch.dict(os.environ, env, clear=True):
from app.model.chat.chat_share import _get_secret_key

key = _get_secret_key()
assert key is not None
assert len(key) > 20 # token_urlsafe(32) produces ~43 chars
# Must NOT be the old hardcoded value
assert key != "EGB1WRC9xMUVgNoIPH8tLw"

def test_get_secret_key_generates_unique_values(self):
"""Each call without env var should produce a different key."""
env = os.environ.copy()
env.pop("CHAT_SHARE_SECRET_KEY", None)
with patch.dict(os.environ, env, clear=True):
from app.model.chat.chat_share import _get_secret_key

key1 = _get_secret_key()
key2 = _get_secret_key()
assert key1 != key2

def test_get_salt_returns_env_var_when_set(self):
"""_get_salt should return the environment variable value."""
with patch.dict(os.environ, {"CHAT_SHARE_SALT": "custom-salt"}):
from app.model.chat.chat_share import _get_salt

assert _get_salt() == "custom-salt"

def test_get_salt_generates_random_when_env_not_set(self):
"""_get_salt should generate a random salt when env var is absent."""
env = os.environ.copy()
env.pop("CHAT_SHARE_SALT", None)
with patch.dict(os.environ, env, clear=True):
from app.model.chat.chat_share import _get_salt

salt = _get_salt()
assert salt is not None
assert len(salt) > 5
# Must NOT be the old hardcoded value
assert salt != "r4U2M"

def test_token_roundtrip_with_random_keys(self):
"""Tokens generated with random keys should verify correctly."""
env = os.environ.copy()
env.pop("CHAT_SHARE_SECRET_KEY", None)
env.pop("CHAT_SHARE_SALT", None)
with patch.dict(os.environ, env, clear=True):
import importlib

from app.model.chat import chat_share

importlib.reload(chat_share)
cls = chat_share.ChatShare
token = cls.generate_token("test-task-id")
result = cls.verify_token(token)
assert result == "test-task-id"
90 changes: 90 additions & 0 deletions server/tests/test_proxy_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ========= 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. =========

from urllib.parse import quote_plus, urlparse, parse_qs

import pytest


class TestGoogleSearchUrlEncoding:
"""Tests for Google Search query URL encoding.

Validates that the query parameter is properly URL-encoded to prevent
broken URLs and parameter injection when queries contain special characters.
"""

def _build_url(self, query: str) -> str:
"""Replicate the URL construction logic from proxy_controller."""
google_api_key = "TEST_KEY"
search_engine_id = "TEST_CX"
start_page_idx = 1
search_language = "en"
num_result_pages = 10

return (
f"https://www.googleapis.com/customsearch/v1?"
f"key={google_api_key}&cx={search_engine_id}&q={quote_plus(query)}&start="
f"{start_page_idx}&lr={search_language}&num={num_result_pages}"
)

def test_simple_query_encoded_correctly(self):
"""A simple query should pass through without issues."""
url = self._build_url("python tutorial")
parsed = urlparse(url)
params = parse_qs(parsed.query)
assert params["q"] == ["python tutorial"]

def test_special_characters_encoded(self):
"""Queries with ampersands and equals signs must be encoded."""
url = self._build_url("key=value&other=test")
parsed = urlparse(url)
params = parse_qs(parsed.query)
# The query should be a single value, not split into multiple params
assert params["q"] == ["key=value&other=test"]
# Verify the original params are preserved
assert params["key"] == ["TEST_KEY"]
assert params["cx"] == ["TEST_CX"]

def test_hash_character_encoded(self):
"""Hash characters must not truncate the URL."""
url = self._build_url("C# programming")
parsed = urlparse(url)
params = parse_qs(parsed.query)
assert params["q"] == ["C# programming"]

def test_unicode_characters_encoded(self):
"""Non-ASCII characters must be properly encoded."""
url = self._build_url("eigent AI asistan")
parsed = urlparse(url)
params = parse_qs(parsed.query)
assert params["q"] == ["eigent AI asistan"]

def test_plus_signs_in_query(self):
"""Plus signs should be encoded (not treated as spaces)."""
url = self._build_url("C++ templates")
assert "C%2B%2B" in url or "C%2B%2B+templates" in url
parsed = urlparse(url)
params = parse_qs(parsed.query)
assert params["q"] == ["C++ templates"]

def test_parameter_injection_prevented(self):
"""A malicious query must not inject extra URL parameters."""
url = self._build_url("test&key=STOLEN_KEY&cx=STOLEN_CX")
parsed = urlparse(url)
params = parse_qs(parsed.query)
# The injected key/cx must not overwrite the real ones
assert params["key"] == ["TEST_KEY"]
assert params["cx"] == ["TEST_CX"]
# The malicious string should be the query value
assert params["q"] == ["test&key=STOLEN_KEY&cx=STOLEN_CX"]
Loading