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
44 changes: 39 additions & 5 deletions backend/app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────

Expand Down Expand Up @@ -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
Expand All @@ -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 ───────────────────────────────────────────

Expand Down
44 changes: 44 additions & 0 deletions backend/tests/test_channel_config_schema.py
Original file line number Diff line number Diff line change
@@ -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"},
}
2 changes: 1 addition & 1 deletion frontend/src/components/ChannelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down