From 9dada2a0d92f65ff6a26bcaa7f4ccb62937753ea Mon Sep 17 00:00:00 2001 From: Weather Date: Sat, 2 May 2026 18:59:46 -0400 Subject: [PATCH] feat: Implements Slack Signing --- .env.template | 1 + docker-compose.yml | 3 ++- src/api/endpoints.py | 25 ++++++++++++++++++-- src/config.py | 2 ++ src/core/slack.py | 55 +++++++++++++++++++++++++++++++++----------- 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/.env.template b/.env.template index 853032e..1e4146b 100644 --- a/.env.template +++ b/.env.template @@ -7,6 +7,7 @@ CALENDAR_TIMEZONE= WATCHED_CHANNELS= SLACK_API_TOKEN= SLACK_JUMPSTART_MESSAGE= +SLACK_SIGNING_SECRET= WIKI_API= WIKIBOT_USER= diff --git a/docker-compose.yml b/docker-compose.yml index 6468237..a16693f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,8 @@ services: - WATCHED_CHANNELS=${WATCHED_CHANNELS} - SLACK_API_TOKEN=${SLACK_API_TOKEN} - SLACK_JUMPSTART_MESSAGE=${SLACK_JUMPSTART_MESSAGE} - + - SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET} + - WIKI_API=${WIKI_API} - WIKIBOT_USER=${WIKIBOT_USER} - WIKIBOT_PASSWORD=${WIKIBOT_PASSWORD} diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 9037866..992ff5a 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse from core import slack, wikithoughts, cshcalendar +import json logger: Logger = getLogger(__name__) router: APIRouter = APIRouter() @@ -56,11 +57,25 @@ async def slack_events(request: Request) -> JSONResponse: JSONResponse: A JSON response indicating the result of the event handling. """ - return JSONResponse(await slack.process_slack_events(request)) + raw_body: bytes = await request.body() + + if not (slack.is_valid_slack_request(request, raw_body)): + logger.warning(f"Received a Fake Slack Event!: {body}") + return JSONResponse({"error": "Invalid signature"}, status_code=403) + + body: dict = json.load(raw_body) + + # Challenge from Bot Authentication + if request.headers.get("content-type") == "application/json": + if body.get("type") == "url_verification": + logger.info("SLACK EVENT: Was a challenge!") + return {"challenge": body.get("challenge")} + + return JSONResponse(await slack.process_slack_events(body)) @router.post("/slack/message_actions") -async def message_actions(payload: str = Form(...)) -> JSONResponse: +async def message_actions(request: Request, payload: str = Form(...)) -> JSONResponse: """ Handles slack message action. @@ -71,6 +86,12 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: JSONResponse: A JSON response indicating the result of the action. """ + raw_body: bytes = await request.body() + + if not (slack.is_valid_slack_request(request, raw_body)): + logger.warning(f"Received a Fake Slack Message Action!") + return JSONResponse({"error": "Invalid signature"}, status_code=403) + response_dict, status_code = await slack.process_slack_message_actions(payload) return JSONResponse(response_dict, status_code=status_code) diff --git a/src/config.py b/src/config.py index 3e3eb81..4ff172c 100644 --- a/src/config.py +++ b/src/config.py @@ -39,6 +39,8 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None) SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" +SLACK_SIGNING_SECRET: str = _get_env_variable("SLACK_SIGNING_SECRET", None) + WATCHED_CHANNELS: tuple[str] = tuple( _get_env_variable("WATCHED_CHANNELS", "").split(",") ) diff --git a/src/core/slack.py b/src/core/slack.py index e95243a..e5ad40c 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -7,6 +7,7 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.slack_response import SlackResponse from slack_sdk.errors import SlackApiError +from slack_sdk.signature import SignatureVerifier from modules import taskmanager @@ -16,6 +17,7 @@ SLACK_DM_TEMPLATE, CALENDAR_TIMEZONE, WATCHED_CHANNELS, + SLACK_SIGNING_SECRET, ) from datetime import datetime @@ -38,11 +40,6 @@ "RAHHHHHHHHHHHHHHHHHHHHHHHH HOW DARE YOU :skeleton-shield-banging-here:" ) -try: - client = AsyncWebClient(token=SLACK_API_TOKEN) -except Exception as e: - logger.error(f"Failed to initialize Slack client: {e}") - current_announcement: dict[str, str] = { "content": "Welcome to Jumpstart!", "user": "Jumpstart", @@ -52,6 +49,41 @@ } +_slack_signature_verifier: SignatureVerifier | None = ( + SignatureVerifier(SLACK_SIGNING_SECRET) if SLACK_SIGNING_SECRET else None +) + +try: + client = AsyncWebClient(token=SLACK_API_TOKEN) +except Exception as e: + logger.error(f"Failed to initialize Slack client: {e}") + + +def is_valid_slack_request(request: Request, raw_body: bytes) -> bool: + """ + Validates Slack's request signature using the signing secret. + + Args: + request (Request): The request to be checked + raw_body (bytes): The raw body + + Returns + bool: Whether or not it's verified + """ + + if _slack_signature_verifier is None: + logger.error("Slack signing secret is not configured") + return False + + try: + return _slack_signature_verifier.is_valid_request( + body=raw_body, + headers=dict(request.headers), + ) + except TypeError, ValueError: + return False + + def clean_text(raw: str) -> str: """ 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: logger.error(f"Error messaging user {user_id}: {e}") -async def process_slack_events(request: Request) -> dict[str, str]: +async def process_slack_events(body: dict) -> dict[str, str]: """ Processes a slack event, logging and returning the result from the event @@ -198,7 +230,6 @@ async def process_slack_events(request: Request) -> dict[str, str]: """ try: - body: dict = await request.json() logger.info(f"Received Slack event: {body}") 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]: ) return ({"status": "success"}, 200) - # Challenge from Bot Authentication - if request.headers.get("content-type") == "application/json": - if body.get("type") == "url_verification": - logger.info("SLACK EVENT: Was a challenge!") - return {"challenge": body.get("challenge")} - event: dict = body.get("event", {}) if event.get("subtype", None) is not None: @@ -267,7 +292,9 @@ async def process_slack_message_actions(payload: str): user_id = form_json.get("user", {}).get("id") username: str = await get_username(user_id) - username = username[:40] # Only get the first 40 characters so it fits on a single line + username = username[ + :40 + ] # Only get the first 40 characters so it fits on a single line add_announcement(message_object, username)