Skip to content

Commit 316953b

Browse files
fix(slack): timing-safe socket-token comparison (port 9824d33 slice) (#126)
Port the Slack slice of upstream 9824d33 (PR #441 adapter-hardening pass): the forwarded Socket Mode events receiver validates the `x-slack-socket-token` bearer against `socket_forwarding_secret`. Encode both operands to UTF-8 bytes before `hmac.compare_digest`, mirroring upstream `timingSafeStringEqual`'s `Buffer.from(x, "utf8")` comparison. This keeps the constant-time guarantee, returns False on length mismatch (the secret length is fixed at config time, so not a leak), and avoids the `TypeError` that `str` `compare_digest` raises on non-ASCII tokens (which would have surfaced as a crash rather than a clean 401). Per CLAUDE.md Port Rule: `==` for signatures -> hmac.compare_digest. Tests: spy on `hmac.compare_digest` to prove the timing-safe path is exercised with byte operands (load-bearing against a regression to `==`), plus a non-ASCII token is rejected with 401 rather than crashing. Existing valid/invalid/missing-secret cases retained. 1 of 4 slices of the 9824d33 security pass (gchat/github/linear slices ship as separate PRs). Refs #98. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59e57d2 commit 316953b

2 files changed

Lines changed: 86 additions & 1 deletion

File tree

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1235,8 +1235,14 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
12351235
# success.
12361236
socket_token = headers.get("x-slack-socket-token") or headers.get("X-Slack-Socket-Token")
12371237
if socket_token:
1238+
# Constant-time bearer comparison (upstream timingSafeStringEqual,
1239+
# 9824d33 / PR #441). Encode both operands to UTF-8 bytes so
1240+
# ``compare_digest`` mirrors upstream's ``Buffer.from(x, "utf8")``
1241+
# comparison: it returns False on length mismatch (the secret's
1242+
# length is fixed at config time, so that is not a leak) and never
1243+
# raises on non-ASCII tokens the way str comparison would.
12381244
if not self._socket_forwarding_secret or not hmac.compare_digest(
1239-
socket_token, self._socket_forwarding_secret
1245+
socket_token.encode(), self._socket_forwarding_secret.encode()
12401246
):
12411247
self._logger.warn("Invalid socket forwarding token")
12421248
return {"body": "Invalid socket token", "status": 401}

tests/test_slack_socket_mode.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,85 @@ async def test_webhook_socket_token_rejected_when_no_secret_configured(self):
343343
result = await adapter.handle_webhook(request)
344344
assert result["status"] == 401
345345

346+
async def test_webhook_socket_token_uses_timing_safe_compare(self, monkeypatch):
347+
"""The socket-token check MUST route through ``hmac.compare_digest``.
348+
349+
Upstream switched this comparison to a constant-time check
350+
(timingSafeStringEqual, 9824d33 / PR #441). This is the load-bearing
351+
guard against a regression to a plain ``==`` / ``!=`` comparison:
352+
we spy on ``hmac.compare_digest`` as referenced by the adapter module
353+
and assert it is invoked for the socket token, with both operands
354+
encoded to bytes (mirroring upstream's ``Buffer.from(x, "utf8")``).
355+
"""
356+
import time as _time
357+
358+
from chat_sdk.adapters.slack import adapter as adapter_mod
359+
360+
calls: list[tuple[Any, Any]] = []
361+
real_compare = adapter_mod.hmac.compare_digest
362+
363+
def _spy_compare(a, b):
364+
calls.append((a, b))
365+
return real_compare(a, b)
366+
367+
monkeypatch.setattr(adapter_mod.hmac, "compare_digest", _spy_compare)
368+
369+
adapter = _make_socket_adapter(socket_forwarding_secret="fwd-secret")
370+
chat = _make_mock_chat()
371+
adapter._chat = chat
372+
forwarded = {
373+
"type": "socket_event",
374+
"eventType": "events_api",
375+
"body": {
376+
"type": "event_callback",
377+
"event": {
378+
"type": "message",
379+
"channel": "C123",
380+
"ts": "1.0",
381+
"user": "U1",
382+
"text": "hi",
383+
"team": "T1",
384+
},
385+
"team_id": "T1",
386+
},
387+
"timestamp": int(_time.time()),
388+
}
389+
request = _FakeRequest(
390+
json.dumps(forwarded),
391+
headers={
392+
"content-type": "application/json",
393+
"x-slack-socket-token": "fwd-secret",
394+
},
395+
)
396+
result = await adapter.handle_webhook(request)
397+
398+
# Valid token => accepted, AND the timing-safe path was exercised
399+
# with byte-encoded operands.
400+
assert result["status"] == 200
401+
assert (b"fwd-secret", b"fwd-secret") in calls, (
402+
f"socket token must be compared via hmac.compare_digest on bytes, not == ; recorded calls: {calls!r}"
403+
)
404+
405+
async def test_webhook_socket_token_non_ascii_rejected_not_crash(self):
406+
"""A non-ASCII socket token must be rejected (401), never crash.
407+
408+
``hmac.compare_digest`` raises ``TypeError`` on non-ASCII ``str``
409+
operands; encoding to bytes (the upstream behavior) avoids that. This
410+
pins the ``.encode()`` so a regression to str comparison surfaces as a
411+
crash here rather than silently in production.
412+
"""
413+
adapter = _make_socket_adapter(socket_forwarding_secret="fwd-secret")
414+
adapter._chat = _make_mock_chat()
415+
request = _FakeRequest(
416+
json.dumps({"type": "socket_event", "eventType": "events_api", "body": {}}),
417+
headers={
418+
"content-type": "application/json",
419+
"x-slack-socket-token": "café-not-the-secret",
420+
},
421+
)
422+
result = await adapter.handle_webhook(request)
423+
assert result["status"] == 401
424+
346425

347426
# ---------------------------------------------------------------------------
348427
# _route_socket_event dispatch

0 commit comments

Comments
 (0)