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
6 changes: 5 additions & 1 deletion server/app/component/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,15 @@ async def auth(


async def auth_must(
token: str = Depends(oauth2_scheme),
token: str | None = Depends(oauth2_scheme),
session: Session = Depends(session),
) -> Auth:
if token is None:
raise TokenException(code.token_invalid, _("Authentication required"))
model = Auth.decode_token(token)
user = session.get(User, model.id)
if not user:
raise TokenException(code.token_invalid, _("User not found"))
model._user = user
return model

Expand Down
1 change: 1 addition & 0 deletions server/app/controller/chat/history_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def list_grouped_chat_history(
"tasks": [],
"total_completed_tasks": 0,
"total_ongoing_tasks": 0,
"total_failed_tasks": 0,
"average_tokens_per_task": 0,
}
)
Expand Down
6 changes: 3 additions & 3 deletions server/app/controller/mcp/mcp_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ async def install(mcp_id: int, session: Session = Depends(session), auth: Auth =
mcp_desc=mcp.description,
type=mcp.type,
status=Status.enable,
command=install_command["command"],
args=install_command["args"],
env=install_command["env"],
command=install_command.get("command"),
args=install_command.get("args", []),
env=install_command.get("env", {}),
server_url=None,
)
mcp_user.save()
Expand Down
14 changes: 11 additions & 3 deletions server/app/controller/mcp/user_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,14 @@ async def list_mcp_users(
@router.get("/mcp/users/{mcp_user_id}", name="get mcp user", response_model=McpUserOut)
async def get_mcp_user(mcp_user_id: int, session: Session = Depends(session), auth: Auth = Depends(auth_must)):
"""Get MCP user details."""
query = select(McpUser).where(McpUser.id == mcp_user_id)
user_id = auth.user.id
query = select(McpUser).where(McpUser.id == mcp_user_id, McpUser.user_id == user_id)
mcp_user = session.exec(query).first()
if not mcp_user:
logger.warning("MCP user not found", extra={"user_id": auth.user.id, "mcp_user_id": mcp_user_id})
logger.warning("MCP user not found", extra={"user_id": user_id, "mcp_user_id": mcp_user_id})
raise HTTPException(status_code=404, detail=_("McpUser not found"))
logger.debug(
"MCP user retrieved", extra={"user_id": auth.user.id, "mcp_user_id": mcp_user_id, "mcp_id": mcp_user.mcp_id}
"MCP user retrieved", extra={"user_id": user_id, "mcp_user_id": mcp_user_id, "mcp_id": mcp_user.mcp_id}
)
return mcp_user

Expand Down Expand Up @@ -193,6 +194,13 @@ async def delete_mcp_user(mcp_user_id: int, session: Session = Depends(session),
logger.warning("MCP user not found for deletion", extra={"user_id": user_id, "mcp_user_id": mcp_user_id})
raise HTTPException(status_code=404, detail=_("Mcp Info not found"))

if db_mcp_user.user_id != user_id:
logger.warning(
"Unauthorized MCP user deletion",
extra={"user_id": user_id, "mcp_user_id": mcp_user_id, "owner_id": db_mcp_user.user_id},
)
raise HTTPException(status_code=403, detail=_("You are not allowed to delete this MCP"))

try:
session.delete(db_mcp_user)
session.commit()
Expand Down
5 changes: 4 additions & 1 deletion server/app/controller/redirect_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import json
from urllib.parse import quote

from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
Expand All @@ -25,6 +26,8 @@ def redirect_callback(code: str, request: Request):
cookies = request.cookies
cookies_json = json.dumps(cookies)

safe_code = quote(code, safe='')

html_content = f"""
<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -72,7 +75,7 @@ def redirect_callback(code: str, request: Request):
<script>
(function() {{
const allCookies = {cookies_json};
const baseUrl = "eigent://callback?code={code}";
const baseUrl = "eigent://callback?code={safe_code}";
let finalUrl = baseUrl;

// 自动跳转到应用
Expand Down
8 changes: 8 additions & 0 deletions server/app/controller/user/login_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ async def by_password(data: LoginByPasswordIn, session: Session = Depends(sessio
logger.warning("Login failed: invalid password", extra={"user_id": user.id, "email": email})
raise UserException(code.password, _("Account or password error"))

if user.status == Status.Block:
logger.warning("Login failed: user is blocked", extra={"user_id": user.id, "email": email})
raise UserException(code.error, _("Your account has been blocked."))

logger.info("User login successful", extra={"user_id": user.id, "email": email})
return LoginResponse(token=Auth.create_access_token(user.id), email=user.email)

Expand Down Expand Up @@ -82,6 +86,10 @@ async def dev_login(
)
raise HTTPException(status_code=401, detail="Incorrect username or password")

if user.status == Status.Block:
logger.warning("OAuth2 login failed: user is blocked", extra={"user_id": user.id, "email": email})
raise HTTPException(status_code=403, detail="Your account has been blocked.")

token = Auth.create_access_token(user.id)
logger.info("OAuth2 login successful", extra={"user_id": user.id, "email": email})

Expand Down
6 changes: 6 additions & 0 deletions server/app/model/chat/chat_snpshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import base64
import os
import re
import time

from pydantic import BaseModel
Expand Down Expand Up @@ -60,8 +61,13 @@ class ChatSnapshotIn(BaseModel):
def save_image(user_id: int, api_task_id: str, image_base64: str) -> str:
if "," in image_base64:
image_base64 = image_base64.split(",", 1)[1]
if not re.match(r'^[a-zA-Z0-9_-]+$', api_task_id):
raise ValueError("Invalid api_task_id: contains disallowed characters")
user_dir = encode_user_id(user_id)
folder = os.path.join("app", "public", "upload", user_dir, api_task_id)
base_dir = os.path.realpath(os.path.join("app", "public", "upload"))
if not os.path.realpath(folder).startswith(base_dir):
raise ValueError("Invalid api_task_id: path traversal detected")
os.makedirs(folder, exist_ok=True)
filename = f"{int(time.time() * 1000)}.jpg"
file_path = os.path.join(folder, filename)
Expand Down
35 changes: 35 additions & 0 deletions server/tests/app/component/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ========= 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 inspect


def test_auth_must_has_none_token_guard():
"""auth_must should accept Optional[str] and raise on None."""
from app.component.auth import auth_must

sig = inspect.signature(auth_must)
token_param = sig.parameters["token"]
annotation = str(token_param.annotation)
assert "None" in annotation or "Optional" in annotation


def test_auth_must_checks_user_exists():
"""auth_must must verify user is not None after DB lookup."""
from app.component.auth import auth_must

source = inspect.getsource(auth_must)
assert "if not user" in source or "user is None" in source, (
"auth_must does not check if user exists after token decode"
)
25 changes: 25 additions & 0 deletions server/tests/app/controller/chat/history_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# ========= 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 inspect


def test_grouped_history_has_total_failed_tasks_key():
"""The defaultdict factory must include total_failed_tasks."""
from app.controller.chat.history_controller import list_grouped_chat_history

source = inspect.getsource(list_grouped_chat_history)
assert '"total_failed_tasks"' in source and "0" in source, (
"defaultdict factory is missing total_failed_tasks key"
)
26 changes: 26 additions & 0 deletions server/tests/app/controller/mcp/mcp_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# ========= 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 inspect


def test_install_command_uses_get():
"""mcp_controller install must use .get() instead of bracket access."""
mod = __import__(
"app.controller.mcp.mcp_controller", fromlist=["install"]
)
source = inspect.getsource(mod.install)
assert "install_command.get(" in source, (
"mcp_controller.install still uses bracket access on install_command"
)
35 changes: 35 additions & 0 deletions server/tests/app/controller/mcp/user_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ========= 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 inspect


def test_get_mcp_user_has_ownership_filter():
"""GET /mcp/users/{id} must filter by user_id."""
from app.controller.mcp.user_controller import get_mcp_user

source = inspect.getsource(get_mcp_user)
assert "McpUser.user_id" in source, (
"get_mcp_user does not filter by user_id - IDOR vulnerability"
)


def test_delete_mcp_user_has_ownership_check():
"""DELETE /mcp/users/{id} must check ownership before deletion."""
from app.controller.mcp.user_controller import delete_mcp_user

source = inspect.getsource(delete_mcp_user)
assert "user_id" in source and "403" in source, (
"delete_mcp_user does not check ownership - IDOR vulnerability"
)
45 changes: 45 additions & 0 deletions server/tests/app/controller/redirect_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ========= 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 inspect

from unittest.mock import MagicMock


def test_redirect_controller_uses_url_encoding():
"""The redirect_callback function must use urllib.parse.quote to encode the code parameter."""
import importlib

mod = importlib.import_module("app.controller.redirect_controller")
source = inspect.getsource(mod.redirect_callback)
assert "quote(" in source or "safe_code" in source, (
"redirect_callback does not encode the code parameter"
)


def test_redirect_callback_escapes_script_injection():
"""An XSS payload in the code parameter must be rendered harmless."""
from app.controller.redirect_controller import redirect_callback

mock_request = MagicMock()
mock_request.cookies = {}
xss_payload = '";alert(document.cookie);//'
response = redirect_callback(code=xss_payload, request=mock_request)
body = response.body.decode()
# The raw payload should NOT appear unescaped in the HTML
assert xss_payload not in body, (
"XSS payload appears unescaped in redirect HTML"
)
# The percent-encoded version should be present
assert "%22" in body or "%3C" in body or "%26" in body
35 changes: 35 additions & 0 deletions server/tests/app/controller/user/login_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ========= 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 inspect


def test_password_login_checks_blocked_status():
"""POST /login must reject blocked users."""
from app.controller.user.login_controller import by_password

source = inspect.getsource(by_password)
assert "Status.Block" in source, (
"by_password does not check user.status == Status.Block"
)


def test_dev_login_checks_blocked_status():
"""POST /dev_login must reject blocked users."""
from app.controller.user.login_controller import dev_login

source = inspect.getsource(dev_login)
assert "Status.Block" in source or "blocked" in source.lower(), (
"dev_login does not check for blocked users"
)
Loading