From f274fd8c85a1f1dfb71659c6316227182300d384 Mon Sep 17 00:00:00 2001 From: yaojin Date: Wed, 5 Aug 2026 19:16:46 +0800 Subject: [PATCH] fix(security): redact channel credentials from responses --- backend/app/schemas/schemas.py | 44 ++++++++++++++++++--- backend/tests/test_channel_config_schema.py | 44 +++++++++++++++++++++ frontend/src/components/ChannelConfig.tsx | 2 +- 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_channel_config_schema.py diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 11b354a01..8b4fd0f35 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -3,8 +3,7 @@ import uuid from datetime import datetime -from pydantic import BaseModel, EmailStr, Field - +from pydantic import BaseModel, EmailStr, Field, field_serializer # ─── Auth ─────────────────────────────────────────────── @@ -462,9 +461,6 @@ class ChannelConfigOut(BaseModel): agent_id: uuid.UUID channel_type: str app_id: str | None = None - app_secret: str | None = None - encrypt_key: str | None = None - verification_token: str | None = None is_configured: bool is_connected: bool last_tested_at: datetime | None = None @@ -473,6 +469,44 @@ class ChannelConfigOut(BaseModel): model_config = {"from_attributes": True} + @field_serializer("extra_config") + def serialize_extra_config(self, value: dict | None) -> dict | None: + """Keep channel credentials out of every API response. + + Channel integrations store provider-specific settings in ``extra_config``. + Those settings can include bot tokens and signing secrets, so applying this + at the shared response schema prevents a newly added channel endpoint from + accidentally disclosing them. + """ + if value is None: + return None + return _redact_channel_secrets(value) + + +_CHANNEL_SECRET_KEY_PARTS = ( + "secret", + "token", + "password", + "credential", + "private_key", + "api_key", + "encrypt_key", + "verification_key", +) + + +def _redact_channel_secrets(value: object) -> object: + """Return a recursively redacted copy of provider-specific configuration.""" + if isinstance(value, dict): + return { + key: _redact_channel_secrets(item) + for key, item in value.items() + if not any(part in key.lower() for part in _CHANNEL_SECRET_KEY_PARTS) + } + if isinstance(value, list): + return [_redact_channel_secrets(item) for item in value] + return value + # ─── Approval ─────────────────────────────────────────── diff --git a/backend/tests/test_channel_config_schema.py b/backend/tests/test_channel_config_schema.py new file mode 100644 index 000000000..51f060eb1 --- /dev/null +++ b/backend/tests/test_channel_config_schema.py @@ -0,0 +1,44 @@ +import uuid +from datetime import UTC, datetime + +from app.models.channel_config import ChannelConfig +from app.schemas.schemas import ChannelConfigOut + + +def test_channel_config_response_excludes_credentials_in_all_channel_endpoints() -> None: + """The shared output schema must not serialize stored channel credentials.""" + config = ChannelConfig( + id=uuid.uuid4(), + agent_id=uuid.uuid4(), + channel_type="slack", + app_id="app-id", + app_secret="bot-token", + encrypt_key="signing-secret", + verification_token="verification-token", + is_configured=True, + is_connected=True, + extra_config={ + "connection_mode": "websocket", + "bot_id": "bot-id", + "bot_secret": "bot-secret", + "nested": {"access_token": "access-token", "safe_setting": "safe"}, + }, + created_at=datetime.now(UTC), + ) + + payload = ChannelConfigOut.model_validate(config).model_dump() + + serialized = str(payload) + assert "app_secret" not in payload + assert "encrypt_key" not in payload + assert "verification_token" not in payload + assert "bot-token" not in serialized + assert "signing-secret" not in serialized + assert "verification-token" not in serialized + assert "bot-secret" not in serialized + assert "access-token" not in serialized + assert payload["extra_config"] == { + "connection_mode": "websocket", + "bot_id": "bot-id", + "nested": {"safe_setting": "safe"}, + } diff --git a/frontend/src/components/ChannelConfig.tsx b/frontend/src/components/ChannelConfig.tsx index 88b615a41..2f9f32d2b 100644 --- a/frontend/src/components/ChannelConfig.tsx +++ b/frontend/src/components/ChannelConfig.tsx @@ -338,7 +338,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, const [wechatLoadingQr, setWechatLoadingQr] = useState(false); // ─── Edit mode: queries for each channel ──────────── - const enabled = mode === 'edit' && !!agentId; + const enabled = mode === 'edit' && !!agentId && canManage; const { data: feishuConfig } = useQuery({ queryKey: ['channel', agentId],