Skip to content

Commit de85c65

Browse files
committed
fix(agent_api_keys): harden webhook trigger setup
1 parent c3d10a4 commit de85c65

3 files changed

Lines changed: 65 additions & 3 deletions

File tree

agentex/src/api/routes/agent_api_keys.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import secrets
3+
from urllib.parse import quote
34

45
from fastapi import APIRouter, HTTPException, Query
56

@@ -38,6 +39,10 @@
3839
)
3940

4041

42+
def _has_control_chars(value: str) -> bool:
43+
return any(ord(char) < 32 or ord(char) == 127 for char in value)
44+
45+
4146
@router.post(
4247
"",
4348
response_model=CreateAPIKeyResponse,
@@ -155,7 +160,7 @@ async def create_webhook_trigger(
155160
),
156161
)
157162

158-
secret = request.secret or secrets.token_hex(32)
163+
secret = request.secret if request.secret is not None else secrets.token_hex(32)
159164
agent_api_key_entity = await agent_api_key_use_case.create(
160165
agent_id=agent.id,
161166
api_key=str(secret),
@@ -164,7 +169,14 @@ async def create_webhook_trigger(
164169
)
165170

166171
forward_path = request.forward_path.lstrip("/")
167-
webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}"
172+
if _has_control_chars(forward_path):
173+
raise HTTPException(
174+
status_code=400,
175+
detail="forward_path must not contain control characters.",
176+
)
177+
encoded_agent_name = quote(request.agent_name, safe="")
178+
encoded_forward_path = quote(forward_path, safe="/")
179+
webhook_path = f"/agents/forward/name/{encoded_agent_name}/{encoded_forward_path}"
168180
base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip(
169181
"/"
170182
)

agentex/src/api/schemas/agent_api_keys.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ class CreateWebhookTriggerRequest(BaseModel):
7676
)
7777
secret: str | None = Field(
7878
None,
79-
description="Optional signing secret; if unset, one is generated and returned.",
79+
description=(
80+
"Signing secret. For GitHub, omit to generate one, or provide an existing "
81+
"webhook secret. For Slack, this is required and must be the Slack app's "
82+
"Signing Secret."
83+
),
8084
)
8185
base_url: str | None = Field(
8286
None,

agentex/tests/unit/api/test_webhook_trigger.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,44 @@ async def test_uses_provided_secret_and_no_url_without_base(self, monkeypatch):
7676
assert resp.webhook_url is None # no AGENTEX_PUBLIC_URL configured
7777
assert resp.webhook_path == "/agents/forward/name/a/gh"
7878

79+
async def test_github_empty_secret_is_preserved(self, monkeypatch):
80+
req = CreateWebhookTriggerRequest(
81+
agent_name="a",
82+
source=AgentAPIKeyType.GITHUB,
83+
name="o/r",
84+
forward_path="gh",
85+
secret="",
86+
)
87+
resp, akuc = await self._call(monkeypatch, req)
88+
assert resp.secret == ""
89+
assert akuc.create.await_args.kwargs["api_key"] == ""
90+
91+
async def test_forward_path_reserved_chars_are_url_encoded(self, monkeypatch):
92+
req = CreateWebhookTriggerRequest(
93+
agent_name="golden agent",
94+
source=AgentAPIKeyType.GITHUB,
95+
name="o/r",
96+
forward_path="github-pr/cfg-9?mode=review#frag ment",
97+
)
98+
resp, _ = await self._call(monkeypatch, req, base_env="https://sgp.example.com/")
99+
100+
assert (
101+
resp.webhook_path
102+
== "/agents/forward/name/golden%20agent/github-pr/cfg-9%3Fmode%3Dreview%23frag%20ment"
103+
)
104+
assert resp.webhook_url == f"https://sgp.example.com{resp.webhook_path}"
105+
106+
async def test_forward_path_control_chars_rejected(self, monkeypatch):
107+
req = CreateWebhookTriggerRequest(
108+
agent_name="a",
109+
source=AgentAPIKeyType.GITHUB,
110+
name="o/r",
111+
forward_path="github-pr/cfg-9\nnext",
112+
)
113+
with pytest.raises(HTTPException) as exc:
114+
await self._call(monkeypatch, req)
115+
assert exc.value.status_code == 400
116+
79117
async def test_conflict_when_key_exists(self, monkeypatch):
80118
req = CreateWebhookTriggerRequest(
81119
agent_name="a", source=AgentAPIKeyType.GITHUB, name="o/r", forward_path="gh"
@@ -118,3 +156,11 @@ async def test_slack_with_provided_secret_ok(self, monkeypatch):
118156
resp, akuc = await self._call(monkeypatch, req)
119157
assert resp.secret == "slack-signing-secret"
120158
assert akuc.create.await_args.kwargs["api_key"] == "slack-signing-secret"
159+
160+
async def test_secret_schema_documents_slack_requirement(self):
161+
description = CreateWebhookTriggerRequest.model_fields["secret"].description
162+
163+
assert description is not None
164+
assert "For GitHub" in description
165+
assert "For Slack" in description
166+
assert "required" in description

0 commit comments

Comments
 (0)