Skip to content

Commit d0ba8bf

Browse files
feat(agentex): add Slack Events API url_verification endpoint (#369)
## What Adds a minimal `POST /slack/events` endpoint that: - echoes Slack's `url_verification` challenge (the one-time Request URL ownership check), and - acks every other event with `200` so Slack stops retrying. Also whitelists `/slack` from authentication — Slack requests can't present an SGP principal, so the handshake must reach the route uncredentialed (same rationale as `/agents/forward`). ## Why Slack's Events API won't accept a Request URL until a live, publicly reachable HTTPS endpoint answers the verification challenge. This is the single piece needed to get past that setup step and mark the URL **Verified**. Once verified, real logic can be added without re-verifying (re-verification only triggers if the URL itself changes). ## Scope Intentionally minimal. Real event handling — signature verification, dispatch to an agent, and reply delivery — is **not** in this PR and lands separately. ## Test - Unit: `TestClient` confirms `url_verification` → `{"challenge": ...}` and other events → `200 {"ok": true}`. - `ruff` / `ruff format` clean. > Note: the `agentex/openapi.yaml` regen pre-commit hook was skipped in the local commit because it writes a spurious gitignored root-level `openapi.yaml` under the hook's run environment; the spec in `agentex/openapi.yaml` was regenerated correctly and is included. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR adds a minimal `POST /slack/events` endpoint to pass Slack's one-time `url_verification` handshake, echoing the challenge back and acking all other events with `200`. It also whitelists the `/slack` prefix from the authentication middleware so Slack's credentialless requests can reach the route. - **New route** (`agentex/src/api/routes/slack.py`): echoes `challenge` for `url_verification` events, returns `{\"ok\": true}` for everything else; no request-body schema or Slack signature validation. - **Auth whitelist** (`agentex/src/api/middleware_utils.py`): adds `\"/slack\"` as a prefix entry, which exempts not just `/slack/events` but any future `/slack/*` route from authentication by default. - **Router registration** (`agentex/src/api/app.py`) and OpenAPI spec (`agentex/openapi.yaml`) are updated accordingly. <details><summary><h3>Confidence Score: 3/5</h3></summary> The new Slack endpoint is live and publicly reachable without credentials from the moment this merges; real event handlers that land in the follow-up PR will inherit the same unauthenticated surface unless signature verification is added first. The endpoint intentionally bypasses authentication and currently does no signature verification. While the current no-op behavior is harmless, the open /slack/events surface and the prefix-level auth whitelist create a footgun for the next PR that adds real event processing. agentex/src/api/routes/slack.py and agentex/src/api/middleware_utils.py — signature verification and whitelist scope both need attention before real event handling is added. </details> <details open><summary><h3>Security Review</h3></summary> - **No Slack request-signature verification** (`agentex/src/api/routes/slack.py`): The `/slack/events` endpoint accepts any POST without validating `X-Slack-Signature` / `X-Slack-Request-Timestamp`. The route is already publicly reachable without credentials; any actor can forge Slack event payloads today, and the risk compounds immediately when real event handling is added in the next PR. - **Overly broad auth bypass** (`agentex/src/api/middleware_utils.py`): The whitelist entry `\"/slack\"` uses prefix matching, so all future `/slack/*` routes are unauthenticated by default unless explicitly carved out. </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/api/routes/slack.py | New Slack Events ingress endpoint — handles url_verification challenge and acks all other events; missing Slack request-signature verification and no JSON parse error handling. | | agentex/src/api/middleware_utils.py | Adds "/slack" prefix to auth whitelist; this silently bypasses auth for all future /slack/* routes, not just /slack/events. | | agentex/src/api/app.py | Registers the new Slack router — straightforward include, no issues. | | agentex/openapi.yaml | Auto-generated OpenAPI spec addition for POST /slack/events — correct and consistent with the new endpoint. | </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Slack participant AuthMiddleware participant SlackEventsRoute Slack->>AuthMiddleware: POST /slack/events Note over AuthMiddleware: /slack prefix is whitelisted — auth skipped AuthMiddleware->>SlackEventsRoute: forward request (no credentials required) alt "type == "url_verification"" SlackEventsRoute-->>Slack: "200 {"challenge": "..."}" else any other event type SlackEventsRoute-->>Slack: "200 {"ok": true}" end ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Slack participant AuthMiddleware participant SlackEventsRoute Slack->>AuthMiddleware: POST /slack/events Note over AuthMiddleware: /slack prefix is whitelisted — auth skipped AuthMiddleware->>SlackEventsRoute: forward request (no credentials required) alt "type == "url_verification"" SlackEventsRoute-->>Slack: "200 {"challenge": "..."}" else any other event type SlackEventsRoute-->>Slack: "200 {"ok": true}" end ``` </a> </details> <a href="https://app.greptile.com/api/ide/cursor?prompt=Fix%20the%20following%203%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%203%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fslack.py%3A20-25%0A**Missing%20Slack%20request-signature%20verification**%0A%0AThe%20endpoint%20accepts%20every%20POST%20without%20checking%20the%20%60X-Slack-Signature%60%20%2F%20%60X-Slack-Request-Timestamp%60%20headers%20that%20Slack%20includes%20on%20every%20request.%20As%20soon%20as%20real%20event%20handling%20lands%20in%20the%20next%20PR%2C%20any%20external%20party%20can%20forge%20Slack%20events%20%28DM%20dispatch%2C%20slash%20commands%2C%20interactive%20callbacks%29%20by%20POSTing%20to%20this%20unauthenticated%20route.%20The%20route%20is%20already%20live%20and%20reachable%20without%20credentials%2C%20so%20the%20attack%20surface%20exists%20from%20the%20moment%20this%20merges%2C%20even%20if%20the%20handling%20is%20currently%20a%20no-op.%20Slack's%20signature%20verification%20is%20a%20one-time%20add%20of%20~10%20lines%20using%20%60hmac.compare_digest%60%20against%20the%20%60SLACK_SIGNING_SECRET%60%3B%20deferring%20it%20past%20the%20first%20real%20handler%20significantly%20increases%20the%20window%20for%20exploitation.%0A%0A%23%23%23%20Issue%202%20of%203%0Aagentex%2Fsrc%2Fapi%2Fmiddleware_utils.py%3A32-33%0A**Prefix%20whitelist%20silently%20covers%20all%20future%20%60%2Fslack%2F*%60%20routes**%0A%0A%60%22%2Fslack%22%60%20uses%20the%20boundary-aware%20prefix%20match%20%28%60startswith%28%22%2Fslack%2F%22%29%60%29%2C%20so%20every%20future%20route%20added%20under%20this%20prefix%20%E2%80%94%20management%20endpoints%2C%20admin%20callbacks%2C%20webhook%20delivery%20%E2%80%94%20will%20be%20unauthenticated%20by%20default.%20Contrast%20with%20%60%2Fagents%2Fregister%60%20and%20%60%2Fagents%2Fforward%60%2C%20which%20are%20explicit%20paths.%20If%20a%20future%20route%20under%20%60%2Fslack%2F%60%20should%20require%20authentication%2C%20the%20developer%20must%20remember%20to%20override%20or%20restructure%20the%20whitelist.%20It%20may%20be%20safer%20to%20add%20the%20exact%20path%20%60%22%2Fslack%2Fevents%22%60%20here%20to%20limit%20the%20blast%20radius.%0A%0A%23%23%23%20Issue%203%20of%203%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fslack.py%3A21-22%0A**Unhandled%20JSON%20parse%20error**%0A%0A%60await%20request.json%28%29%60%20raises%20an%20exception%20if%20the%20body%20is%20not%20valid%20JSON%20%28e.g.%20a%20malformed%20webhook%20retry%20or%20a%20probe%29%2C%20yielding%20an%20unhandled%20500%20rather%20than%20a%20graceful%20400.%20Wrapping%20this%20in%20a%20%60try%2Fexcept%60%20with%20an%20early%20%60raise%20HTTPException%28status_code%3D400%29%60%20keeps%20the%20error%20surface%20clean%20and%20avoids%20surfacing%20stack%20traces%20in%20logs.%0A%0A&pr=369&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursorDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=6"><img alt="Fix All in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=6"></picture></a> <a href="https://app.greptile.com/ide/claude-code?prompt=Fix%20the%20following%203%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%203%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fslack.py%3A20-25%0A**Missing%20Slack%20request-signature%20verification**%0A%0AThe%20endpoint%20accepts%20every%20POST%20without%20checking%20the%20%60X-Slack-Signature%60%20%2F%20%60X-Slack-Request-Timestamp%60%20headers%20that%20Slack%20includes%20on%20every%20request.%20As%20soon%20as%20real%20event%20handling%20lands%20in%20the%20next%20PR%2C%20any%20external%20party%20can%20forge%20Slack%20events%20%28DM%20dispatch%2C%20slash%20commands%2C%20interactive%20callbacks%29%20by%20POSTing%20to%20this%20unauthenticated%20route.%20The%20route%20is%20already%20live%20and%20reachable%20without%20credentials%2C%20so%20the%20attack%20surface%20exists%20from%20the%20moment%20this%20merges%2C%20even%20if%20the%20handling%20is%20currently%20a%20no-op.%20Slack's%20signature%20verification%20is%20a%20one-time%20add%20of%20~10%20lines%20using%20%60hmac.compare_digest%60%20against%20the%20%60SLACK_SIGNING_SECRET%60%3B%20deferring%20it%20past%20the%20first%20real%20handler%20significantly%20increases%20the%20window%20for%20exploitation.%0A%0A%23%23%23%20Issue%202%20of%203%0Aagentex%2Fsrc%2Fapi%2Fmiddleware_utils.py%3A32-33%0A**Prefix%20whitelist%20silently%20covers%20all%20future%20%60%2Fslack%2F*%60%20routes**%0A%0A%60%22%2Fslack%22%60%20uses%20the%20boundary-aware%20prefix%20match%20%28%60startswith%28%22%2Fslack%2F%22%29%60%29%2C%20so%20every%20future%20route%20added%20under%20this%20prefix%20%E2%80%94%20management%20endpoints%2C%20admin%20callbacks%2C%20webhook%20delivery%20%E2%80%94%20will%20be%20unauthenticated%20by%20default.%20Contrast%20with%20%60%2Fagents%2Fregister%60%20and%20%60%2Fagents%2Fforward%60%2C%20which%20are%20explicit%20paths.%20If%20a%20future%20route%20under%20%60%2Fslack%2F%60%20should%20require%20authentication%2C%20the%20developer%20must%20remember%20to%20override%20or%20restructure%20the%20whitelist.%20It%20may%20be%20safer%20to%20add%20the%20exact%20path%20%60%22%2Fslack%2Fevents%22%60%20here%20to%20limit%20the%20blast%20radius.%0A%0A%23%23%23%20Issue%203%20of%203%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fslack.py%3A21-22%0A**Unhandled%20JSON%20parse%20error**%0A%0A%60await%20request.json%28%29%60%20raises%20an%20exception%20if%20the%20body%20is%20not%20valid%20JSON%20%28e.g.%20a%20malformed%20webhook%20retry%20or%20a%20probe%29%2C%20yielding%20an%20unhandled%20500%20rather%20than%20a%20graceful%20400.%20Wrapping%20this%20in%20a%20%60try%2Fexcept%60%20with%20an%20early%20%60raise%20HTTPException%28status_code%3D400%29%60%20keeps%20the%20error%20surface%20clean%20and%20avoids%20surfacing%20stack%20traces%20in%20logs.%0A%0A&repo=scaleapi%2Fscale-agentex&pr=369&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaudeDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=6"><img alt="Fix All in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=6"></picture></a> <a href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22mc%2Fslack-dummy-endpoint%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22mc%2Fslack-dummy-endpoint%22.%0A%0AFix%20the%20following%203%20code%20review%20issues.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%203%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fslack.py%3A20-25%0A**Missing%20Slack%20request-signature%20verification**%0A%0AThe%20endpoint%20accepts%20every%20POST%20without%20checking%20the%20%60X-Slack-Signature%60%20%2F%20%60X-Slack-Request-Timestamp%60%20headers%20that%20Slack%20includes%20on%20every%20request.%20As%20soon%20as%20real%20event%20handling%20lands%20in%20the%20next%20PR%2C%20any%20external%20party%20can%20forge%20Slack%20events%20%28DM%20dispatch%2C%20slash%20commands%2C%20interactive%20callbacks%29%20by%20POSTing%20to%20this%20unauthenticated%20route.%20The%20route%20is%20already%20live%20and%20reachable%20without%20credentials%2C%20so%20the%20attack%20surface%20exists%20from%20the%20moment%20this%20merges%2C%20even%20if%20the%20handling%20is%20currently%20a%20no-op.%20Slack's%20signature%20verification%20is%20a%20one-time%20add%20of%20~10%20lines%20using%20%60hmac.compare_digest%60%20against%20the%20%60SLACK_SIGNING_SECRET%60%3B%20deferring%20it%20past%20the%20first%20real%20handler%20significantly%20increases%20the%20window%20for%20exploitation.%0A%0A%23%23%23%20Issue%202%20of%203%0Aagentex%2Fsrc%2Fapi%2Fmiddleware_utils.py%3A32-33%0A**Prefix%20whitelist%20silently%20covers%20all%20future%20%60%2Fslack%2F*%60%20routes**%0A%0A%60%22%2Fslack%22%60%20uses%20the%20boundary-aware%20prefix%20match%20%28%60startswith%28%22%2Fslack%2F%22%29%60%29%2C%20so%20every%20future%20route%20added%20under%20this%20prefix%20%E2%80%94%20management%20endpoints%2C%20admin%20callbacks%2C%20webhook%20delivery%20%E2%80%94%20will%20be%20unauthenticated%20by%20default.%20Contrast%20with%20%60%2Fagents%2Fregister%60%20and%20%60%2Fagents%2Fforward%60%2C%20which%20are%20explicit%20paths.%20If%20a%20future%20route%20under%20%60%2Fslack%2F%60%20should%20require%20authentication%2C%20the%20developer%20must%20remember%20to%20override%20or%20restructure%20the%20whitelist.%20It%20may%20be%20safer%20to%20add%20the%20exact%20path%20%60%22%2Fslack%2Fevents%22%60%20here%20to%20limit%20the%20blast%20radius.%0A%0A%23%23%23%20Issue%203%20of%203%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fslack.py%3A21-22%0A**Unhandled%20JSON%20parse%20error**%0A%0A%60await%20request.json%28%29%60%20raises%20an%20exception%20if%20the%20body%20is%20not%20valid%20JSON%20%28e.g.%20a%20malformed%20webhook%20retry%20or%20a%20probe%29%2C%20yielding%20an%20unhandled%20500%20rather%20than%20a%20graceful%20400.%20Wrapping%20this%20in%20a%20%60try%2Fexcept%60%20with%20an%20early%20%60raise%20HTTPException%28status_code%3D400%29%60%20keeps%20the%20error%20surface%20clean%20and%20avoids%20surfacing%20stack%20traces%20in%20logs.%0A%0A&repo=scaleapi%2Fscale-agentex&pr=369&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img alt="Fix All in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a> <details><summary>Prompt To Fix All With AI</summary> `````markdown Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes. --- ### Issue 1 of 3 agentex/src/api/routes/slack.py:20-25 **Missing Slack request-signature verification** The endpoint accepts every POST without checking the `X-Slack-Signature` / `X-Slack-Request-Timestamp` headers that Slack includes on every request. As soon as real event handling lands in the next PR, any external party can forge Slack events (DM dispatch, slash commands, interactive callbacks) by POSTing to this unauthenticated route. The route is already live and reachable without credentials, so the attack surface exists from the moment this merges, even if the handling is currently a no-op. Slack's signature verification is a one-time add of ~10 lines using `hmac.compare_digest` against the `SLACK_SIGNING_SECRET`; deferring it past the first real handler significantly increases the window for exploitation. ### Issue 2 of 3 agentex/src/api/middleware_utils.py:32-33 **Prefix whitelist silently covers all future `/slack/*` routes** `"/slack"` uses the boundary-aware prefix match (`startswith("/slack/")`), so every future route added under this prefix — management endpoints, admin callbacks, webhook delivery — will be unauthenticated by default. Contrast with `/agents/register` and `/agents/forward`, which are explicit paths. If a future route under `/slack/` should require authentication, the developer must remember to override or restructure the whitelist. It may be safer to add the exact path `"/slack/events"` here to limit the blast radius. ### Issue 3 of 3 agentex/src/api/routes/slack.py:21-22 **Unhandled JSON parse error** `await request.json()` raises an exception if the body is not valid JSON (e.g. a malformed webhook retry or a probe), yielding an unhandled 500 rather than a graceful 400. Wrapping this in a `try/except` with an early `raise HTTPException(status_code=400)` keeps the error surface clean and avoids surfacing stack traces in logs. ````` </details> <sub>Reviews (1): Last reviewed commit: ["feat(agentex): add Slack Events API url\_..."](3215134) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=46036999)</sub> > Greptile also left **3 inline comments** on this PR. <!-- /greptile_comment --> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0d859c2 commit d0ba8bf

4 files changed

Lines changed: 45 additions & 0 deletions

File tree

agentex/openapi.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2536,6 +2536,21 @@ paths:
25362536
application/json:
25372537
schema:
25382538
$ref: '#/components/schemas/HTTPValidationError'
2539+
/slack/events:
2540+
post:
2541+
tags:
2542+
- Slack
2543+
summary: Slack Events API ingress
2544+
operationId: slack_events_slack_events_post
2545+
responses:
2546+
'200':
2547+
description: Successful Response
2548+
content:
2549+
application/json:
2550+
schema:
2551+
additionalProperties: true
2552+
type: object
2553+
title: Response Slack Events Slack Events Post
25392554
/tracker/{tracker_id}:
25402555
get:
25412556
tags:

agentex/src/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
deployments,
3939
events,
4040
messages,
41+
slack,
4142
spans,
4243
states,
4344
task_retention,
@@ -200,6 +201,7 @@ async def handle_unexpected(request, exc):
200201
fastapi_app.include_router(spans.router)
201202
fastapi_app.include_router(states.router)
202203
fastapi_app.include_router(events.router)
204+
fastapi_app.include_router(slack.router)
203205
fastapi_app.include_router(agent_task_tracker.router)
204206
fastapi_app.include_router(agent_api_keys.router)
205207
fastapi_app.include_router(deployment_history.router)

agentex/src/api/middleware_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"/readyz",
2929
"/ping",
3030
"/echo",
31+
# Slack can't present an SGP principal; the url_verification handshake must
32+
# reach /slack/events without credentials.
33+
"/slack",
3134
}
3235

3336
DROP_HEADERS: set[str] = {

agentex/src/api/routes/slack.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Slack Events API ingress — POST /slack/events.
2+
3+
Minimal endpoint whose only job is to pass Slack's one-time Request URL
4+
verification. On save, Slack POSTs ``{"type": "url_verification", "challenge": ...}``
5+
and expects the ``challenge`` echoed back; every other event is acked with 200 so
6+
Slack stops retrying. Real event handling (signature verification, dispatch, reply
7+
delivery) is intentionally not here yet.
8+
9+
Auth-whitelisted (see ``WHITELISTED_ROUTES``) because Slack can't present an SGP
10+
principal — the challenge handshake must reach this route without credentials.
11+
"""
12+
13+
from typing import Any
14+
15+
from fastapi import APIRouter, Request
16+
17+
router = APIRouter(prefix="/slack", tags=["Slack"])
18+
19+
20+
@router.post("/events", summary="Slack Events API ingress")
21+
async def slack_events(request: Request) -> dict[str, Any]:
22+
payload = await request.json()
23+
if payload.get("type") == "url_verification":
24+
return {"challenge": payload.get("challenge")}
25+
return {"ok": True}

0 commit comments

Comments
 (0)