Skip to content

Commit fabf23a

Browse files
Merge pull request #108 from ComputerScienceHouse/slack-verification
feat: Implements Slack Signing
2 parents 638a94e + 9dada2a commit fabf23a

5 files changed

Lines changed: 69 additions & 17 deletions

File tree

‎.env.template‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CALENDAR_TIMEZONE=
77
WATCHED_CHANNELS=
88
SLACK_API_TOKEN=
99
SLACK_JUMPSTART_MESSAGE=
10+
SLACK_SIGNING_SECRET=
1011

1112
WIKI_API=
1213
WIKIBOT_USER=

‎docker-compose.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ services:
1515
- WATCHED_CHANNELS=${WATCHED_CHANNELS}
1616
- SLACK_API_TOKEN=${SLACK_API_TOKEN}
1717
- SLACK_JUMPSTART_MESSAGE=${SLACK_JUMPSTART_MESSAGE}
18-
18+
- SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET}
19+
1920
- WIKI_API=${WIKI_API}
2021
- WIKIBOT_USER=${WIKIBOT_USER}
2122
- WIKIBOT_PASSWORD=${WIKIBOT_PASSWORD}

‎src/api/endpoints.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from fastapi.responses import JSONResponse
55

66
from core import slack, wikithoughts, cshcalendar
7+
import json
78

89
logger: Logger = getLogger(__name__)
910
router: APIRouter = APIRouter()
@@ -56,11 +57,25 @@ async def slack_events(request: Request) -> JSONResponse:
5657
JSONResponse: A JSON response indicating the result of the event handling.
5758
"""
5859

59-
return JSONResponse(await slack.process_slack_events(request))
60+
raw_body: bytes = await request.body()
61+
62+
if not (slack.is_valid_slack_request(request, raw_body)):
63+
logger.warning(f"Received a Fake Slack Event!: {body}")
64+
return JSONResponse({"error": "Invalid signature"}, status_code=403)
65+
66+
body: dict = json.load(raw_body)
67+
68+
# Challenge from Bot Authentication
69+
if request.headers.get("content-type") == "application/json":
70+
if body.get("type") == "url_verification":
71+
logger.info("SLACK EVENT: Was a challenge!")
72+
return {"challenge": body.get("challenge")}
73+
74+
return JSONResponse(await slack.process_slack_events(body))
6075

6176

6277
@router.post("/slack/message_actions")
63-
async def message_actions(payload: str = Form(...)) -> JSONResponse:
78+
async def message_actions(request: Request, payload: str = Form(...)) -> JSONResponse:
6479
"""
6580
Handles slack message action.
6681
@@ -71,6 +86,12 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse:
7186
JSONResponse: A JSON response indicating the result of the action.
7287
"""
7388

89+
raw_body: bytes = await request.body()
90+
91+
if not (slack.is_valid_slack_request(request, raw_body)):
92+
logger.warning(f"Received a Fake Slack Message Action!")
93+
return JSONResponse({"error": "Invalid signature"}, status_code=403)
94+
7495
response_dict, status_code = await slack.process_slack_message_actions(payload)
7596
return JSONResponse(response_dict, status_code=status_code)
7697

‎src/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None:
3939

4040
SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None)
4141
SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?"
42+
SLACK_SIGNING_SECRET: str = _get_env_variable("SLACK_SIGNING_SECRET", None)
43+
4244
WATCHED_CHANNELS: tuple[str] = tuple(
4345
_get_env_variable("WATCHED_CHANNELS", "").split(",")
4446
)

‎src/core/slack.py‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from slack_sdk.web.async_client import AsyncWebClient
88
from slack_sdk.web.slack_response import SlackResponse
99
from slack_sdk.errors import SlackApiError
10+
from slack_sdk.signature import SignatureVerifier
1011

1112
from modules import taskmanager
1213

@@ -16,6 +17,7 @@
1617
SLACK_DM_TEMPLATE,
1718
CALENDAR_TIMEZONE,
1819
WATCHED_CHANNELS,
20+
SLACK_SIGNING_SECRET,
1921
)
2022

2123
from datetime import datetime
@@ -38,11 +40,6 @@
3840
"RAHHHHHHHHHHHHHHHHHHHHHHHH HOW DARE YOU :skeleton-shield-banging-here:"
3941
)
4042

41-
try:
42-
client = AsyncWebClient(token=SLACK_API_TOKEN)
43-
except Exception as e:
44-
logger.error(f"Failed to initialize Slack client: {e}")
45-
4643
current_announcement: dict[str, str] = {
4744
"content": "Welcome to Jumpstart!",
4845
"user": "Jumpstart",
@@ -52,6 +49,41 @@
5249
}
5350

5451

52+
_slack_signature_verifier: SignatureVerifier | None = (
53+
SignatureVerifier(SLACK_SIGNING_SECRET) if SLACK_SIGNING_SECRET else None
54+
)
55+
56+
try:
57+
client = AsyncWebClient(token=SLACK_API_TOKEN)
58+
except Exception as e:
59+
logger.error(f"Failed to initialize Slack client: {e}")
60+
61+
62+
def is_valid_slack_request(request: Request, raw_body: bytes) -> bool:
63+
"""
64+
Validates Slack's request signature using the signing secret.
65+
66+
Args:
67+
request (Request): The request to be checked
68+
raw_body (bytes): The raw body
69+
70+
Returns
71+
bool: Whether or not it's verified
72+
"""
73+
74+
if _slack_signature_verifier is None:
75+
logger.error("Slack signing secret is not configured")
76+
return False
77+
78+
try:
79+
return _slack_signature_verifier.is_valid_request(
80+
body=raw_body,
81+
headers=dict(request.headers),
82+
)
83+
except TypeError, ValueError:
84+
return False
85+
86+
5587
def clean_text(raw: str) -> str:
5688
"""
5789
Strip Slack mrkdwn, HTML entities, and formatting characters.
@@ -186,7 +218,7 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None:
186218
logger.error(f"Error messaging user {user_id}: {e}")
187219

188220

189-
async def process_slack_events(request: Request) -> dict[str, str]:
221+
async def process_slack_events(body: dict) -> dict[str, str]:
190222
"""
191223
Processes a slack event, logging and returning the result from the event
192224
@@ -198,7 +230,6 @@ async def process_slack_events(request: Request) -> dict[str, str]:
198230
"""
199231

200232
try:
201-
body: dict = await request.json()
202233
logger.info(f"Received Slack event: {body}")
203234

204235
event_amounts: int = get_event_retry_amount(body.get("event_id", None))
@@ -208,12 +239,6 @@ async def process_slack_events(request: Request) -> dict[str, str]:
208239
)
209240
return ({"status": "success"}, 200)
210241

211-
# Challenge from Bot Authentication
212-
if request.headers.get("content-type") == "application/json":
213-
if body.get("type") == "url_verification":
214-
logger.info("SLACK EVENT: Was a challenge!")
215-
return {"challenge": body.get("challenge")}
216-
217242
event: dict = body.get("event", {})
218243

219244
if event.get("subtype", None) is not None:
@@ -267,7 +292,9 @@ async def process_slack_message_actions(payload: str):
267292
user_id = form_json.get("user", {}).get("id")
268293

269294
username: str = await get_username(user_id)
270-
username = username[:40] # Only get the first 40 characters so it fits on a single line
295+
username = username[
296+
:40
297+
] # Only get the first 40 characters so it fits on a single line
271298

272299
add_announcement(message_object, username)
273300

0 commit comments

Comments
 (0)