fix(channels): harden runtime credential management APIs (#3581)

* fix(channels): harden runtime credential management APIs

* fix(channels): address review feedback on credential hardening

Follow-up to the runtime credential-hardening pass, resolving five review
findings:

- WeChat auth persistence now writes through a 0o600 NamedTemporaryFile +
  Path.replace instead of write_text-then-chmod, so the iLink bot_token is
  never briefly readable at umask defaults (mirrors ChannelRuntimeConfigStore).
- The post-write chmod is split into its own try/except: a chmod failure on a
  filesystem without POSIX perms now logs at debug instead of masquerading as
  a "failed to persist" warning.
- Extracted the three near-identical _require_admin_user helpers (mcp,
  channel_connections, channels) into a single require_admin_user(request, *,
  detail) in app/gateway/deps.py; each router supplies its own detail string.
- Strengthened the runtime-config-store chmod coverage: a new test injects a
  temp-file chmod failure and asserts it is logged at debug while the
  destination is still owner-only (mutation-verified to fail if the chmod is
  dropped), plus a loose-pre-existing-file case.
- Removed the unused _FakeRepo from the blocking-io test: its isinstance gate
  routes through the repo-less 503 path, so neither stub was ever invoked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Nan Gao
2026-06-18 04:45:33 +02:00
committed by GitHub
parent 68ba4198b8
commit 2b301e8211
11 changed files with 314 additions and 56 deletions
@@ -51,6 +51,10 @@ class ChannelRuntimeConfigStore:
delete=False, delete=False,
) )
try: try:
try:
Path(fd.name).chmod(0o600)
except OSError:
logger.debug("Unable to chmod temporary channel runtime config store at %s", fd.name, exc_info=True)
json.dump(self._data, fd, indent=2, ensure_ascii=False) json.dump(self._data, fd, indent=2, ensure_ascii=False)
fd.close() fd.close()
Path(fd.name).replace(self._path) Path(fd.name).replace(self._path)
+22 -1
View File
@@ -10,6 +10,7 @@ import json
import logging import logging
import mimetypes import mimetypes
import secrets import secrets
import tempfile
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from enum import IntEnum from enum import IntEnum
@@ -1376,9 +1377,29 @@ class WechatChannel(Channel):
if self._auth_path: if self._auth_path:
try: try:
self._auth_path.parent.mkdir(parents=True, exist_ok=True) self._auth_path.parent.mkdir(parents=True, exist_ok=True)
self._auth_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") # Write through a 0o600 temp file and atomically rename so the
# iLink bot_token is never briefly readable at umask defaults
# (mirrors ChannelRuntimeConfigStore._save). NamedTemporaryFile
# uses mkstemp, which creates the file at 0o600 from the start.
fd = tempfile.NamedTemporaryFile(mode="w", dir=self._auth_path.parent, suffix=".tmp", delete=False, encoding="utf-8")
try:
json.dump(data, fd, ensure_ascii=False, indent=2)
fd.close()
Path(fd.name).replace(self._auth_path)
except BaseException:
fd.close()
Path(fd.name).unlink(missing_ok=True)
raise
except OSError: except OSError:
logger.warning("[WeChat] failed to persist auth state to %s", self._auth_path) logger.warning("[WeChat] failed to persist auth state to %s", self._auth_path)
else:
# Hardening only; the destination already inherits 0o600 from the
# temp file. A chmod failure on filesystems without POSIX perms
# must not masquerade as a persist failure.
try:
self._auth_path.chmod(0o600)
except OSError:
logger.debug("[WeChat] unable to chmod auth state at %s", self._auth_path, exc_info=True)
return data return data
@staticmethod @staticmethod
+22
View File
@@ -377,6 +377,28 @@ async def get_current_user_from_request(request: Request):
return user return user
async def require_admin_user(request: Request, *, detail: str) -> None:
"""Require the authenticated caller to be an admin user.
``AuthMiddleware`` normally stamps ``request.state.user`` before the request
reaches a router. Falling back to the strict dependency keeps the route safe
in tests or alternative ASGI compositions that mount a router without the
global middleware. ``detail`` is the route-specific 403 message.
Centralising this here means a future change to the admin definition (e.g.
allowing an internal system role, adding audit logging, or switching to a
permission-based check) lands in one place instead of drifting across the
per-router copies that previously existed in ``mcp``, ``channel_connections``
and ``channels``.
"""
user = getattr(request.state, "user", None)
if user is None:
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail=detail)
async def get_optional_user_from_request(request: Request): async def get_optional_user_from_request(request: Request):
"""Get optional authenticated user from request. """Get optional authenticated user from request.
@@ -16,6 +16,7 @@ from app.channels.runtime_config_store import (
apply_runtime_connection_config, apply_runtime_connection_config,
merge_runtime_channel_configs, merge_runtime_channel_configs,
) )
from app.gateway.deps import require_admin_user
from deerflow.config.channel_connections_config import ChannelConnectionsConfig from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.persistence.channel_connections import ChannelConnectionRepository from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory from deerflow.persistence.engine import get_session_factory
@@ -26,6 +27,7 @@ logger = logging.getLogger(__name__)
_STATE_TTL_SECONDS = 600 _STATE_TTL_SECONDS = 600
_MAX_PENDING_CONNECT_CODES_PER_PROVIDER = 5 _MAX_PENDING_CONNECT_CODES_PER_PROVIDER = 5
_MASKED_CREDENTIAL_VALUE = "********" _MASKED_CREDENTIAL_VALUE = "********"
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage channel runtime credentials."
class ChannelCredentialFieldResponse(BaseModel): class ChannelCredentialFieldResponse(BaseModel):
@@ -135,24 +137,6 @@ def _get_user_id(request: Request) -> str:
return str(user.id) return str(user.id)
async def _require_admin_user(request: Request) -> None:
"""Require an admin caller for instance-wide channel runtime mutations.
Runtime credentials and the channel workers they start/stop are shared by
every user of the deployment, so only admins may change them (same model
as the MCP config API). Auth-disabled local mode uses a synthetic admin
user and is unaffected.
"""
user = getattr(request.state, "user", None)
if user is None:
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Admin privileges required to manage channel runtime credentials.")
def _get_app_config(): def _get_app_config():
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
@@ -572,7 +556,7 @@ async def disconnect_channel_connection(connection_id: str, request: Request) ->
@router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse) @router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse)
async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse: async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse:
await _require_admin_user(request) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = await _get_channel_connections_config(request) config = await _get_channel_connections_config(request)
if not config.enabled: if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled") raise HTTPException(status_code=400, detail="Channel connections are disabled")
@@ -658,7 +642,7 @@ async def configure_channel_provider_runtime(
body: ChannelRuntimeConfigRequest, body: ChannelRuntimeConfigRequest,
request: Request, request: Request,
) -> ChannelProviderResponse: ) -> ChannelProviderResponse:
await _require_admin_user(request) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = await _get_channel_connections_config(request) config = await _get_channel_connections_config(request)
if not config.enabled: if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled") raise HTTPException(status_code=400, detail="Channel connections are disabled")
+8 -2
View File
@@ -4,13 +4,17 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from app.gateway.deps import require_admin_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/channels", tags=["channels"]) router = APIRouter(prefix="/api/channels", tags=["channels"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage channel runtime workers."
class ChannelStatusResponse(BaseModel): class ChannelStatusResponse(BaseModel):
service_running: bool service_running: bool
@@ -35,8 +39,10 @@ async def get_channels_status() -> ChannelStatusResponse:
@router.post("/{name}/restart", response_model=ChannelRestartResponse) @router.post("/{name}/restart", response_model=ChannelRestartResponse)
async def restart_channel(name: str) -> ChannelRestartResponse: async def restart_channel(name: str, request: Request) -> ChannelRestartResponse:
"""Restart a specific IM channel.""" """Restart a specific IM channel."""
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
from app.channels.service import get_channel_service from app.channels.service import get_channel_service
service = get_channel_service() service = get_channel_service()
+6 -24
View File
@@ -7,12 +7,15 @@ from typing import Literal
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.gateway.deps import require_admin_user
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
from deerflow.mcp.cache import reset_mcp_tools_cache from deerflow.mcp.cache import reset_mcp_tools_cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["mcp"]) router = APIRouter(prefix="/api", tags=["mcp"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage MCP configuration."
_MCP_STDIO_COMMAND_ALLOWLIST_ENV = "DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST" _MCP_STDIO_COMMAND_ALLOWLIST_ENV = "DEER_FLOW_MCP_STDIO_COMMAND_ALLOWLIST"
_DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST = frozenset({"npx", "uvx"}) _DEFAULT_MCP_STDIO_COMMAND_ALLOWLIST = frozenset({"npx", "uvx"})
@@ -80,27 +83,6 @@ class McpCacheResetResponse(BaseModel):
_MASKED_VALUE = "***" _MASKED_VALUE = "***"
async def _require_admin_user(request: Request) -> None:
"""Require the authenticated caller to be an admin user.
``AuthMiddleware`` normally stamps ``request.state.user`` before the
request reaches this router. Falling back to the strict dependency keeps
this route safe even in tests or alternative ASGI compositions that mount
the router without the global middleware.
"""
user = getattr(request.state, "user", None)
if user is None:
from app.gateway.deps import get_current_user_from_request
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required to manage MCP configuration.",
)
def _allowed_stdio_commands() -> set[str]: def _allowed_stdio_commands() -> set[str]:
"""Return executable names allowed for API-managed stdio MCP servers.""" """Return executable names allowed for API-managed stdio MCP servers."""
raw = os.environ.get(_MCP_STDIO_COMMAND_ALLOWLIST_ENV) raw = os.environ.get(_MCP_STDIO_COMMAND_ALLOWLIST_ENV)
@@ -269,7 +251,7 @@ async def get_mcp_configuration(request: Request) -> McpConfigResponse:
} }
``` ```
""" """
await _require_admin_user(request) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
config = get_extensions_config() config = get_extensions_config()
@@ -290,7 +272,7 @@ async def reset_mcp_tools_cache_endpoint(request: Request) -> McpCacheResetRespo
servers. This affects all threads and users in the current Gateway process, servers. This affects all threads and users in the current Gateway process,
and avoids relying on extensions_config.json mtime changes. and avoids relying on extensions_config.json mtime changes.
""" """
await _require_admin_user(request) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
reset_mcp_tools_cache() reset_mcp_tools_cache()
return McpCacheResetResponse( return McpCacheResetResponse(
success=True, success=True,
@@ -337,7 +319,7 @@ async def update_mcp_configuration(request: Request, body: McpConfigUpdateReques
``` ```
""" """
try: try:
await _require_admin_user(request) await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
_validate_mcp_update_request(body) _validate_mcp_update_request(body)
# Get the current config path (or determine where to save it) # Get the current config path (or determine where to save it)
+19
View File
@@ -124,10 +124,29 @@ Connection records live in SQL tables under `deerflow.persistence.channel_connec
Incoming messages that resolve to a connection carry `connection_id`, `owner_user_id`, and `workspace_id`. `ChannelManager` uses `owner_user_id` as the DeerFlow run user id and preserves the raw platform user id as `channel_user_id`. Incoming messages that resolve to a connection carry `connection_id`, `owner_user_id`, and `workspace_id`. `ChannelManager` uses `owner_user_id` as the DeerFlow run user id and preserves the raw platform user id as `channel_user_id`.
Runtime provider credentials are deployment-level bot secrets, not user-owned
connection credentials. They can come from `channels.*` in `config.yaml` or
from the browser runtime setup flow, which persists them through
`ChannelRuntimeConfigStore` so local/private deployments can configure bots
without editing YAML. The runtime store is a local plaintext JSON fallback with
owner-only file permissions (`0600`); use it only where the DeerFlow data
directory is already trusted as secret storage. WeChat QR login auth state
follows the same local-runtime model and may persist a QR-derived bot token in
the channel state directory.
## Security Notes ## Security Notes
- Browser APIs remain authenticated and CSRF-protected. - Browser APIs remain authenticated and CSRF-protected.
- Connect codes are 128-bit random, short-lived, and single-use. - Connect codes are 128-bit random, short-lived, and single-use.
- Runtime provider bot tokens are shared deployment secrets. Runtime setup
responses mask password fields, and mutating runtime/channel-worker APIs
require an admin user.
- Stored per-connection credentials use the `channel_credentials` encryption
path. If stored credential material cannot be decrypted, DeerFlow treats it
as unavailable instead of using corrupt secrets.
- The local plaintext runtime credential fallback is documented above; prefer
deployment-managed environment/config secrets for non-local deployments until
a dedicated secret backend is configured.
- `allowed_users` is **not** a bind-time defense. Because connect codes are processed before the allowlist (see Connect Flow), anyone who possesses a valid code can consume it — not only allowlisted users. Bind security therefore rests entirely on the code's confidentiality: it is 128-bit random, expires after 10 minutes, is single-use, and is shown only in the initiating user's browser (never echoed back to chat). Treat connect codes like one-time passwords and do not forward them. - `allowed_users` is **not** a bind-time defense. Because connect codes are processed before the allowlist (see Connect Flow), anyone who possesses a valid code can consume it — not only allowlisted users. Bind security therefore rests entirely on the code's confidentiality: it is 128-bit random, expires after 10 minutes, is single-use, and is shown only in the initiating user's browser (never echoed back to chat). Treat connect codes like one-time passwords and do not forward them.
- An external identity — `(provider, external account, workspace/team/guild)` — has at most one active owner. The most recent successful bind wins: connecting an identity that another DeerFlow user already holds transfers ownership and revokes the previous owner's binding (and its stored credentials). This is enforced at the database layer, so two users racing to bind the same identity cannot both end up connected. - An external identity — `(provider, external account, workspace/team/guild)` — has at most one active owner. The most recent successful bind wins: connecting an identity that another DeerFlow user already holds transfers ownership and revokes the previous owner's binding (and its stored credentials). This is enforced at the database layer, so two users racing to bind the same identity cannot both end up connected.
- Provider bot tokens remain in `channels.*` and are never returned to the browser. - Provider bot tokens remain in `channels.*` and are never returned to the browser.
@@ -17,7 +17,10 @@ from __future__ import annotations
import asyncio import asyncio
import importlib import importlib
import logging
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock
from uuid import UUID from uuid import UUID
import pytest import pytest
@@ -55,18 +58,16 @@ def _make_request(tmp_path) -> Request:
} }
) )
app.state.channels_config = {} app.state.channels_config = {}
app.state.channel_connection_repo = _FakeRepo() # No channel_connection_repo is set: _get_repository's isinstance gate then
# falls through to get_session_factory() (None in tests) and the handlers
# take the repo-less 503 path. These tests only assert the store's file IO
# is offloaded off the event loop, so the DB repo is intentionally absent.
store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json") store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app.state.channel_runtime_config_store = store app.state.channel_runtime_config_store = store
user = SimpleNamespace(id=UUID("11111111-2222-3333-4444-555555555555"), system_role="admin") user = SimpleNamespace(id=UUID("11111111-2222-3333-4444-555555555555"), system_role="admin")
return Request({"type": "http", "app": app, "headers": [], "state": {"user": user}}) return Request({"type": "http", "app": app, "headers": [], "state": {"user": user}})
class _FakeRepo:
async def list_connections(self, owner_user_id):
return []
async def test_configure_runtime_channel_does_not_block_event_loop(tmp_path) -> None: async def test_configure_runtime_channel_does_not_block_event_loop(tmp_path) -> None:
request = await asyncio.to_thread(_make_request, tmp_path) request = await asyncio.to_thread(_make_request, tmp_path)
@@ -104,3 +105,71 @@ async def test_disconnect_runtime_channel_does_not_block_event_loop(tmp_path) ->
"enabled": False, "enabled": False,
"_runtime_disabled": True, "_runtime_disabled": True,
} }
async def test_runtime_config_store_file_is_owner_only(tmp_path) -> None:
path = tmp_path / "channels" / "runtime-config.json"
store = await asyncio.to_thread(ChannelRuntimeConfigStore, path)
await asyncio.to_thread(
store.set_provider_config,
"slack",
{"enabled": True, "bot_token": "xoxb-ui", "app_token": "xapp-ui"},
)
mode = await asyncio.to_thread(lambda: path.stat().st_mode & 0o777)
assert mode == 0o600
async def test_runtime_config_store_overwrites_loose_existing_file(tmp_path) -> None:
"""A pre-existing world-readable file is tightened to 0o600 after a save.
``NamedTemporaryFile`` would yield 0o600 on a fresh path regardless of the
code under test, so seed the destination at 0o644 first: only the store's
atomic 0o600-temp + replace path produces an owner-only file here.
"""
path = tmp_path / "channels" / "runtime-config.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}", encoding="utf-8")
path.chmod(0o644)
store = await asyncio.to_thread(ChannelRuntimeConfigStore, path)
await asyncio.to_thread(
store.set_provider_config,
"slack",
{"enabled": True, "bot_token": "xoxb-ui"},
)
mode = await asyncio.to_thread(lambda: path.stat().st_mode & 0o777)
assert mode == 0o600
async def test_runtime_config_store_chmod_failure_is_logged_not_fatal(tmp_path, caplog) -> None:
"""A chmod failure on the temp file is logged at debug and never aborts the save.
This is the line the previous owner-only assertion could not protect: with the
pre-rename chmod patched to raise, the save must still persist the secret and
the destination must still end up owner-only (via the temp file's mkstemp mode
that ``Path.replace`` preserves). If the chmod call were dropped, the expected
debug record would be absent and this test would fail.
"""
path = tmp_path / "channels" / "runtime-config.json"
store = await asyncio.to_thread(ChannelRuntimeConfigStore, path)
real_chmod = Path.chmod
def chmod_spy(self: Path, mode: int, *args, **kwargs):
if self.suffix == ".tmp":
raise OSError("chmod unsupported on this filesystem")
return real_chmod(self, mode, *args, **kwargs)
def _save_with_failing_temp_chmod() -> None:
with caplog.at_level(logging.DEBUG, logger="app.channels.runtime_config_store"), mock.patch.object(Path, "chmod", chmod_spy):
store.set_provider_config("slack", {"enabled": True, "bot_token": "xoxb-ui"})
await asyncio.to_thread(_save_with_failing_temp_chmod)
assert any("Unable to chmod temporary channel runtime config store" in record.getMessage() for record in caplog.records)
mode = await asyncio.to_thread(lambda: path.stat().st_mode & 0o777)
assert mode == 0o600
assert await asyncio.to_thread(store.get_provider_config, "slack") == {"enabled": True, "bot_token": "xoxb-ui"}
+86
View File
@@ -0,0 +1,86 @@
"""Router tests for legacy IM channel management endpoints."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import UUID
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.gateway.auth.models import User
from app.gateway.routers import channels
def _admin_user() -> User:
return User(
id=UUID("11111111-2222-3333-4444-555555555555"),
email="admin@example.com",
password_hash="x",
system_role="admin",
)
def _non_admin_user() -> User:
return User(
id=UUID("99999999-8888-7777-6666-555555555555"),
email="user@example.com",
password_hash="x",
system_role="user",
)
def test_restart_channel_requires_admin(monkeypatch):
service = SimpleNamespace(restart_channel=AsyncMock(return_value=True))
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
app = make_authed_test_app(user_factory=_non_admin_user)
app.include_router(channels.router)
with TestClient(app) as client:
response = client.post("/api/channels/slack/restart")
assert response.status_code == 403
assert "Admin privileges" in response.json()["detail"]
service.restart_channel.assert_not_awaited()
def test_restart_channel_allows_admin(monkeypatch):
service = SimpleNamespace(restart_channel=AsyncMock(return_value=True))
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
app = make_authed_test_app(user_factory=_admin_user)
app.include_router(channels.router)
with TestClient(app) as client:
response = client.post("/api/channels/slack/restart")
assert response.status_code == 200
assert response.json() == {
"success": True,
"message": "Channel slack restarted successfully",
}
service.restart_channel.assert_awaited_once_with("slack")
def test_get_channels_status_remains_read_only(monkeypatch):
service = SimpleNamespace(
get_status=lambda: {
"service_running": True,
"channels": {
"slack": {
"enabled": True,
"running": True,
}
},
}
)
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
app = make_authed_test_app(user_factory=_non_admin_user)
app.include_router(channels.router)
with TestClient(app) as client:
response = client.get("/api/channels/")
assert response.status_code == 200
assert response.json()["service_running"] is True
assert response.json()["channels"]["slack"]["running"] is True
+4 -3
View File
@@ -12,15 +12,16 @@ from types import SimpleNamespace
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from app.gateway.deps import require_admin_user
from app.gateway.routers import mcp as mcp_router from app.gateway.routers import mcp as mcp_router
from app.gateway.routers.mcp import ( from app.gateway.routers.mcp import (
_ADMIN_REQUIRED_DETAIL,
_MCP_STDIO_COMMAND_ALLOWLIST_ENV, _MCP_STDIO_COMMAND_ALLOWLIST_ENV,
McpConfigUpdateRequest, McpConfigUpdateRequest,
McpOAuthConfigResponse, McpOAuthConfigResponse,
McpServerConfigResponse, McpServerConfigResponse,
_mask_server_config, _mask_server_config,
_merge_preserving_secrets, _merge_preserving_secrets,
_require_admin_user,
_validate_mcp_update_request, _validate_mcp_update_request,
reset_mcp_tools_cache_endpoint, reset_mcp_tools_cache_endpoint,
update_mcp_configuration, update_mcp_configuration,
@@ -334,10 +335,10 @@ def _request_with_role(system_role: str):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mcp_config_requires_admin_user(): async def test_mcp_config_requires_admin_user():
"""MCP config is system-level executable configuration, not a normal user setting.""" """MCP config is system-level executable configuration, not a normal user setting."""
await _require_admin_user(_request_with_role("admin")) await require_admin_user(_request_with_role("admin"), detail=_ADMIN_REQUIRED_DETAIL)
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await _require_admin_user(_request_with_role("user")) await require_admin_user(_request_with_role("user"), detail=_ADMIN_REQUIRED_DETAIL)
assert exc_info.value.status_code == 403 assert exc_info.value.status_code == 403
+64
View File
@@ -5,8 +5,10 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest import mock
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
@@ -1310,5 +1312,67 @@ def test_qrcode_login_binds_and_persists_auth_state(monkeypatch, tmp_path: Path)
assert auth_state["status"] == "confirmed" assert auth_state["status"] == "confirmed"
assert auth_state["bot_token"] == "bound-token" assert auth_state["bot_token"] == "bound-token"
assert auth_state["ilink_bot_id"] == "bot-99" assert auth_state["ilink_bot_id"] == "bot-99"
assert ((state_dir / "wechat-auth.json").stat().st_mode & 0o777) == 0o600
_run(go()) _run(go())
def test_save_auth_state_tightens_preexisting_loose_file(tmp_path: Path):
"""A world-readable auth file is replaced by an owner-only one, atomically.
The bot_token must never be observable at loose permissions: the atomic
0o600-temp + ``Path.replace`` path swaps in a fresh owner-only inode rather
than truncating the existing 0o644 file in place. Seeding the destination at
0o644 first means a regression back to ``write_text`` + late ``chmod`` would
leave a detectable window (and, here, the temp-file artifact behind).
"""
from app.channels.wechat import WechatChannel
state_dir = tmp_path / "wechat-state"
state_dir.mkdir(parents=True, exist_ok=True)
auth_path = state_dir / "wechat-auth.json"
auth_path.write_text(json.dumps({"status": "pending"}), encoding="utf-8")
auth_path.chmod(0o644)
channel = WechatChannel(
bus=MessageBus(),
config={"state_dir": str(state_dir), "qrcode_login_enabled": True},
)
channel._save_auth_state(status="confirmed", bot_token="bound-token", ilink_bot_id="bot-1")
assert (auth_path.stat().st_mode & 0o777) == 0o600
assert json.loads(auth_path.read_text(encoding="utf-8"))["bot_token"] == "bound-token"
# Atomic write leaves no temp-file residue behind.
assert list(state_dir.glob("*.tmp")) == []
def test_save_auth_state_chmod_failure_is_logged_not_warned(tmp_path: Path, caplog):
"""A chmod failure on a perms-less filesystem must not look like a persist failure.
With the post-replace chmod split into its own try/except, a chmod ``OSError``
is logged at debug while the JSON is genuinely on disk — operators must not see
the misleading ``failed to persist`` warning that the shared try/except produced.
"""
from app.channels.wechat import WechatChannel
state_dir = tmp_path / "wechat-state"
channel = WechatChannel(
bus=MessageBus(),
config={"state_dir": str(state_dir), "qrcode_login_enabled": True},
)
real_chmod = Path.chmod
def chmod_spy(self: Path, mode: int, *args, **kwargs):
if self.suffix == ".json":
raise OSError("chmod unsupported on this filesystem")
return real_chmod(self, mode, *args, **kwargs)
with caplog.at_level(logging.DEBUG, logger="app.channels.wechat"), mock.patch.object(Path, "chmod", chmod_spy):
channel._save_auth_state(status="confirmed", bot_token="bound-token")
auth_path = state_dir / "wechat-auth.json"
assert json.loads(auth_path.read_text(encoding="utf-8"))["bot_token"] == "bound-token"
messages = [record.getMessage() for record in caplog.records]
assert any("unable to chmod auth state" in message for message in messages)
assert not any("failed to persist auth state" in message for message in messages)