Skip to content

Commit 9acb04f

Browse files
committed
fix: reject malformed unsigned requests instead of crashing before signature check
Non-Slack clients (scanners, bots) can send any JSON shape to a public endpoint. Building the request context happens before the RequestVerification middleware runs, so a body with unexpected value types, a JSON array, or broken JSON used to raise TypeError, AttributeError, or JSONDecodeError. That turned an unsigned request into a 500 instead of the expected 401. - Every extract_* helper now checks the value type before using it - parse_body treats non-object or invalid JSON as an empty body - build_context / build_async_context guard the response_urls shape - Add unit tests for the malformed shapes and end-to-end tests that assert a 401 for both App and AsyncApp Fixes #1447
1 parent a9dee3b commit 9acb04f

5 files changed

Lines changed: 151 additions & 21 deletions

File tree

‎slack_bolt/request/async_internals.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def build_async_context(
6262
elif "response_urls" in body:
6363
# In the case where response_url_enabled: true in a modal exists
6464
response_urls = body["response_urls"]
65-
if len(response_urls) >= 1:
65+
if isinstance(response_urls, list) and len(response_urls) >= 1 and isinstance(response_urls[0], dict):
6666
if len(response_urls) > 1:
6767
context.logger.debug(debug_multiple_response_urls_detected())
6868
response_url = response_urls[0].get("response_url")

‎slack_bolt/request/internals.py‎

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,54 @@ def parse_query(query: Optional[Union[str, Dict[str, str], Dict[str, Sequence[st
2424
raise ValueError(f"Unsupported type of query detected ({type(query)})")
2525

2626

27+
def _parse_json_object(text: str) -> Dict[str, Any]:
28+
# Slack always sends a JSON object. Anything else (broken JSON, an array, a number, ...)
29+
# cannot come from Slack, so it is treated as an empty body. This lets the request reach
30+
# the signature check, which then rejects it, instead of failing while being parsed.
31+
try:
32+
parsed = json.loads(text)
33+
except ValueError:
34+
return {}
35+
return parsed if isinstance(parsed, dict) else {}
36+
37+
2738
def parse_body(body: str, content_type: Optional[str]) -> Dict[str, Any]:
2839
if not body:
2940
return {}
3041
if (content_type is not None and content_type == "application/json") or body.startswith("{"):
31-
return json.loads(body)
42+
return _parse_json_object(body)
3243
else:
3344
if "payload" in body: # This is not JSON format yet
3445
params = dict(parse_qsl(body, keep_blank_values=True))
3546
payload = params.get("payload")
3647
if payload is not None:
37-
return json.loads(payload)
48+
return _parse_json_object(payload)
3849
else:
3950
return {}
4051
else:
4152
return dict(parse_qsl(body, keep_blank_values=True))
4253

4354

55+
def _first_authorization(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
56+
# Returns payload["authorizations"][0] only when it really is a dict
57+
authorizations = payload.get("authorizations")
58+
if isinstance(authorizations, list) and len(authorizations) > 0 and isinstance(authorizations[0], dict):
59+
return authorizations[0]
60+
return None
61+
62+
63+
def _event(payload: Dict[str, Any]) -> Dict[str, Any]:
64+
# Returns payload["event"] when it is a dict, otherwise an empty dict
65+
event = payload.get("event")
66+
return event if isinstance(event, dict) else {}
67+
68+
4469
def extract_is_enterprise_install(payload: Dict[str, Any]) -> Optional[bool]:
45-
if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
70+
authorization = _first_authorization(payload)
71+
if authorization is not None:
4672
# To make Events API handling functioning also for shared channels,
4773
# we should use .authorizations[0].is_enterprise_install over .is_enterprise_install
48-
return extract_is_enterprise_install(payload["authorizations"][0])
74+
return extract_is_enterprise_install(authorization)
4975
if "is_enterprise_install" in payload:
5076
is_enterprise_install = payload.get("is_enterprise_install")
5177
return is_enterprise_install is not None and (is_enterprise_install is True or is_enterprise_install == "true")
@@ -57,12 +83,13 @@ def extract_enterprise_id(payload: Dict[str, Any]) -> Optional[str]:
5783
if org is not None:
5884
if isinstance(org, str):
5985
return org
60-
elif "id" in org:
86+
elif isinstance(org, dict) and "id" in org:
6187
return org.get("id")
62-
if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
88+
authorization = _first_authorization(payload)
89+
if authorization is not None:
6390
# To make Events API handling functioning also for shared channels,
6491
# we should use .authorizations[0].enterprise_id over .enterprise_id
65-
return extract_enterprise_id(payload["authorizations"][0])
92+
return extract_enterprise_id(authorization)
6693
if "enterprise_id" in payload:
6794
return payload.get("enterprise_id")
6895
if isinstance(payload.get("team"), dict) and "enterprise_id" in payload["team"]:
@@ -78,7 +105,7 @@ def extract_actor_enterprise_id(payload: Dict[str, Any]) -> Optional[str]:
78105
if payload.get("type") == "event_callback":
79106
# For safety, we don't set actor IDs for the events like "file_shared",
80107
# which do not provide any team ID in $.event data. In the case, the IDs cannot be correct.
81-
event_team_id = payload.get("event", {}).get("user_team") or payload.get("event", {}).get("team")
108+
event_team_id = _event(payload).get("user_team") or _event(payload).get("team")
82109
if event_team_id is not None and str(event_team_id).startswith("E"):
83110
return event_team_id
84111
if event_team_id == payload.get("team_id"):
@@ -101,12 +128,13 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]:
101128
team = payload.get("team")
102129
if isinstance(team, str):
103130
return team
104-
elif team and "id" in team:
131+
elif isinstance(team, dict) and "id" in team:
105132
return team.get("id")
106-
if payload.get("authorizations") is not None and len(payload["authorizations"]) > 0:
133+
authorization = _first_authorization(payload)
134+
if authorization is not None:
107135
# To make Events API handling functioning also for shared channels,
108136
# we should use .authorizations[0].team_id over .team_id
109-
return extract_team_id(payload["authorizations"][0])
137+
return extract_team_id(authorization)
110138
if "team_id" in payload:
111139
return payload.get("team_id")
112140
if isinstance(payload.get("event"), dict):
@@ -121,22 +149,23 @@ def extract_team_id(payload: Dict[str, Any]) -> Optional[str]:
121149
def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]:
122150
if payload.get("is_ext_shared_channel") is True:
123151
if payload.get("type") == "event_callback":
124-
event_type = payload.get("event", {}).get("type")
152+
event = _event(payload)
153+
event_type = event.get("type")
125154
if event_type == "app_mention":
126155
# The $.event.user_team can be an enterprise_id in app_mention events.
127156
# In the scenario, there is no way to retrieve actor_team_id as of March 2023
128-
user_team = payload.get("event", {}).get("user_team")
157+
user_team = event.get("user_team")
129158
if user_team is None:
130159
# working with an app installed in this user's org/workspace side
131-
return payload.get("event", {}).get("team")
160+
return event.get("team")
132161
if str(user_team).startswith("T"):
133162
# interacting from a connected non-grid workspace
134163
return user_team
135164
# Interacting from a connected grid workspace; in this case, team_id cannot be resolved as of March 2023
136165
return None
137166
# For safety, we don't set actor IDs for the events like "file_shared",
138167
# which do not provide any team ID in $.event data. In the case, the IDs cannot be correct.
139-
event_user_team = payload.get("event", {}).get("user_team")
168+
event_user_team = event.get("user_team")
140169
if event_user_team is not None:
141170
if str(event_user_team).startswith("T"):
142171
return event_user_team
@@ -146,7 +175,7 @@ def extract_actor_team_id(payload: Dict[str, Any]) -> Optional[str]:
146175
elif event_user_team == payload.get("context_enterprise_id"):
147176
return payload.get("context_team_id")
148177

149-
event_team = payload.get("event", {}).get("team")
178+
event_team = event.get("team")
150179
if event_team is not None:
151180
if str(event_team).startswith("T"):
152181
return event_team
@@ -165,7 +194,7 @@ def extract_user_id(payload: Dict[str, Any]) -> Optional[str]:
165194
if user is not None:
166195
if isinstance(user, str):
167196
return user
168-
elif "id" in user:
197+
elif isinstance(user, dict) and "id" in user:
169198
return user.get("id")
170199
if "user_id" in payload:
171200
return payload.get("user_id")
@@ -184,7 +213,7 @@ def extract_actor_user_id(payload: Dict[str, Any]) -> Optional[str]:
184213
if payload.get("is_ext_shared_channel") is True:
185214
if payload.get("type") == "event_callback":
186215
event = payload.get("event")
187-
if event is None:
216+
if not isinstance(event, dict):
188217
return None
189218
if extract_actor_enterprise_id(payload) is None and extract_actor_team_id(payload) is None:
190219
# When both enterprise_id and team_id are not identified, we skip returning user_id too for safety
@@ -198,7 +227,7 @@ def extract_channel_id(payload: Dict[str, Any]) -> Optional[str]:
198227
if channel is not None:
199228
if isinstance(channel, str):
200229
return channel
201-
elif "id" in channel:
230+
elif isinstance(channel, dict) and "id" in channel:
202231
return channel.get("id")
203232
if "channel_id" in payload:
204233
return payload.get("channel_id")
@@ -295,7 +324,7 @@ def build_context(context: BoltContext, body: Dict[str, Any]) -> BoltContext:
295324
elif "response_urls" in body:
296325
# In the case where response_url_enabled: true in a modal exists
297326
response_urls = body["response_urls"]
298-
if len(response_urls) >= 1:
327+
if isinstance(response_urls, list) and len(response_urls) >= 1 and isinstance(response_urls[0], dict):
299328
if len(response_urls) > 1:
300329
context.logger.debug(debug_multiple_response_urls_detected())
301330
response_url = response_urls[0].get("response_url")

‎tests/scenario_tests/test_app.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ def handle_app_mention(body, say: Say, payload, event):
4747
def simple_listener(self, ack):
4848
ack()
4949

50+
def test_malformed_unsigned_request_is_rejected_not_crashed(self):
51+
# A non-Slack client can send any JSON shape. The request must still reach the
52+
# signature check and get a 401, instead of failing while the context is built.
53+
app = App(client=self.web_client, signing_secret=self.signing_secret)
54+
bodies = [
55+
'{"type":"event_callback","event":{"type":"member_joined_channel","user":"U1","channel":"C1"}}',
56+
'{"authorizations":"x","team":1,"user":2,"channel":3,"enterprise":4}',
57+
'{"is_ext_shared_channel":true,"type":"event_callback","event":"s"}',
58+
'{"response_urls":["x"]}',
59+
"[1, 2, 3]",
60+
"{not json",
61+
"payload=%5B1%5D",
62+
]
63+
for body in bodies:
64+
req = BoltRequest(body=body, headers={"content-type": ["application/json"]})
65+
resp = app.dispatch(req)
66+
assert resp.status == 401, body
67+
5068
def test_listener_registration_error(self):
5169
app = App(signing_secret="valid", client=self.web_client)
5270
with pytest.raises(BoltError):

‎tests/scenario_tests_async/test_app.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ def teardown_method(self):
4646
def non_coro_func(self, ack):
4747
ack()
4848

49+
@pytest.mark.asyncio
50+
async def test_malformed_unsigned_request_is_rejected_not_crashed(self):
51+
# A non-Slack client can send any JSON shape. The request must still reach the
52+
# signature check and get a 401, instead of failing while the context is built.
53+
app = AsyncApp(
54+
client=AsyncWebClient(token=self.valid_token, base_url=self.mock_api_server_base_url),
55+
signing_secret=self.signing_secret,
56+
)
57+
bodies = [
58+
'{"type":"event_callback","event":{"type":"member_joined_channel","user":"U1","channel":"C1"}}',
59+
'{"authorizations":"x","team":1,"user":2,"channel":3,"enterprise":4}',
60+
'{"is_ext_shared_channel":true,"type":"event_callback","event":"s"}',
61+
'{"response_urls":["x"]}',
62+
"[1, 2, 3]",
63+
"{not json",
64+
"payload=%5B1%5D",
65+
]
66+
for body in bodies:
67+
req = AsyncBoltRequest(body=body, headers={"content-type": ["application/json"]})
68+
resp = await app.async_dispatch(req)
69+
assert resp.status == 401, body
70+
4971
def test_non_coroutine_func_listener(self):
5072
app = AsyncApp(signing_secret="valid", token="xoxb-xxx")
5173
with pytest.raises(BoltError):

‎tests/slack_bolt/request/test_internals.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
extract_actor_user_id,
1515
extract_function_execution_id,
1616
extract_thread_ts,
17+
build_context,
18+
parse_body,
1719
)
20+
from slack_bolt.context import BoltContext
1821

1922

2023
class TestRequestInternals:
@@ -1275,3 +1278,61 @@ def test_extraction_functions_invalid_dict_keys(self):
12751278
extract_function_execution_id(payload)
12761279
extract_function_bot_access_token(payload)
12771280
extract_function_inputs(payload)
1281+
1282+
def test_extraction_functions_wrong_value_types(self):
1283+
# Bodies sent by non-Slack clients (scanners, bots) can put any JSON value under
1284+
# a key where Slack normally sends a dict or list. None of these should raise.
1285+
payloads = [
1286+
{"authorizations": "x"},
1287+
{"authorizations": [None]},
1288+
{"authorizations": ["str"]},
1289+
{"authorizations": [123]},
1290+
{"authorizations": {}},
1291+
{"enterprise": 123},
1292+
{"enterprise": ["a"]},
1293+
{"team": 123},
1294+
{"team": ["a"]},
1295+
{"user": 5},
1296+
{"user": ["a"]},
1297+
{"channel": 7},
1298+
{"channel": ["a"]},
1299+
{"event": None},
1300+
{"is_ext_shared_channel": True, "type": "event_callback", "event": "s"},
1301+
{"is_ext_shared_channel": True, "type": "event_callback", "event": None},
1302+
{"is_ext_shared_channel": True, "type": "event_callback", "event": 1},
1303+
{"is_ext_shared_channel": True, "type": "event_callback", "event": {"type": "app_mention"}},
1304+
{"response_urls": "abc"},
1305+
{"response_urls": [None]},
1306+
{"response_urls": ["x"]},
1307+
{"response_urls": []},
1308+
{"view": {"app_installed_team_id": None}, "team": None},
1309+
]
1310+
for payload in payloads:
1311+
assert extract_is_enterprise_install(payload) in (True, False)
1312+
extract_enterprise_id(payload)
1313+
extract_team_id(payload)
1314+
extract_user_id(payload)
1315+
extract_actor_enterprise_id(payload)
1316+
extract_actor_team_id(payload)
1317+
extract_actor_user_id(payload)
1318+
extract_channel_id(payload)
1319+
extract_thread_ts(payload)
1320+
extract_function_execution_id(payload)
1321+
extract_function_bot_access_token(payload)
1322+
extract_function_inputs(payload)
1323+
build_context(BoltContext(), payload)
1324+
1325+
def test_parse_body_non_object_json(self):
1326+
# A JSON array or scalar is never a valid Slack payload, so it is treated as an empty body
1327+
assert parse_body("[1, 2]", "application/json") == {}
1328+
assert parse_body('"text"', "application/json") == {}
1329+
assert parse_body("123", "application/json") == {}
1330+
assert parse_body("null", "application/json") == {}
1331+
assert parse_body("payload=%5B1%5D", "application/x-www-form-urlencoded") == {}
1332+
1333+
def test_parse_body_invalid_json(self):
1334+
# Broken JSON is also treated as an empty body so the request can still be rejected
1335+
# by the signature check instead of failing while being parsed
1336+
assert parse_body("{not json", "application/json") == {}
1337+
assert parse_body("{", None) == {}
1338+
assert parse_body("payload=%7Bnot", "application/x-www-form-urlencoded") == {}

0 commit comments

Comments
 (0)