fix(bluebubbles): timing-safe webhook auth + fail closed when password unset - #51328
fix(bluebubbles): timing-safe webhook auth + fail closed when password unset#51328Bartok9 wants to merge 3 commits into
Conversation
…d unset The BlueBubbles webhook handler authenticated callers with a plain `token != self.password` comparison. Two problems: 1. Fail-open: when no password is configured (self.password is None/"") a caller that also omits the token compares None != None -> False, so the request is accepted and dispatched unauthenticated. 2. The plain == leaks token length/content via response timing. Reject unconditionally when no password is configured, and use hmac.compare_digest for the constant-time comparison.
Related: re-implements the fail-closed + timing-safe BlueBubbles webhook auth fix from the now-closed #36862 (and issue #36849). #6608 (open) addresses the same adapter via a different mechanism (a dedicated |
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
Two security improvements for BlueBubbles webhook auth: (1) fail closed when password is unset (previously None != None = False let unauthenticated requests through), (2) constant-time comparison via hmac.compare_digest to prevent timing side channels. Includes regression tests for both fixes.
Reviewed by Hermes Agent
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused BlueBubbles authentication hardening. Current main still uses token != self.password in gateway/platforms/bluebubbles.py:876-884, so the constant-time comparison is a useful, narrowly scoped improvement.
Problems
- The fail-closed test does not represent a reachable live-webhook state.
BlueBubblesAdapter.connect()returns whenself.passwordis falsy atgateway/platforms/bluebubbles.py:240-244; the only aiohttp webhook binding is created later atgateway/platforms/bluebubbles.py:275-277. Directly assigningadapter.password = Nonein the test bypasses that lifecycle invariant.
Suggested changes
- Keep the
hmac.compare_digestchange, but replace the direct-mutation regression with a lifecycle assertion that an unconfigured adapter cannot connect. This matches the withdrawal rationale recorded on related #36862/#36849 while preserving the independent timing-hardening value.
Automated hermes-sweeper review.
| # self.password`` compared ``None != None`` -> False and accepted the | ||
| # request unauthenticated. | ||
| adapter = _make_adapter(monkeypatch) | ||
| adapter.password = None |
There was a problem hiding this comment.
This mutation bypasses the lifecycle invariant: connect() rejects an empty password before binding _handle_webhook (gateway/platforms/bluebubbles.py:240-277). Please test that connection refusal instead; this does not reproduce a reachable live-endpoint bypass.
|
Thanks @teknium1 — agreed the direct |
Keep hmac.compare_digest hardening. Replace direct adapter.password=None webhook mutation with an assert that connect() returns early when password is falsy so the aiohttp webhook is never bound (NousResearch#51328 review).
|
suggesting changes
Please compare UTF-8-encoded bytes while retaining Security evidence:
Not checked:
Signed: GPT-5.6-sol-xhigh in Codex |
hmac.compare_digest on str raises TypeError for non-ASCII operands, turning Unicode query/header credentials into HTTP 500 instead of 401 and breaking legitimately configured non-ASCII passwords. Encode both sides as UTF-8 before compare_digest and add wrong/correct Unicode regressions.
|
Thanks @egilewski — confirmed. Pushed a fix that UTF-8-encodes both sides before
|
|
Thanks @alt-glitch — noted on the cluster with #9219 / closed #36862 / #6608. Relative to open #9219 (timing-safe
Happy for maintainers to pick a canonical approach (merge this, fold the UTF-8 + lifecycle pieces into #9219, or go the #6608 credential-separation route). No objection to closing this as duplicate if #9219 is extended to cover (1)–(3). |
Problem
The BlueBubbles webhook handler (
_handle_webhook) authenticates inboundcallers with a plain comparison:
Two defects:
self.passworddefaults toextra.get("password") or os.getenv("BLUEBUBBLES_PASSWORD", "")— i.e.""(or
Noneif the adapter is mutated). When unset, a caller that also omitsthe token yields
None != None→False(or"" != ""), so the request isaccepted and dispatched unauthenticated. A misconfigured server silently
accepts anonymous webhook posts.
!=short-circuits on the first differingbyte, leaking token length/content via response timing.
Root cause
The handler never special-cased the unconfigured-password state, and used
!=instead of a constant-time compare for a shared secret.
Fix
self.passwordis falsy — fail closed.hmac.compare_digest(str(token or ""), str(self.password))so amismatch can't be recovered from response timing.
+19/-2across 2 files (1 source, 1 test).Tests (real output)
New regression class
TestBlueBubblesWebhookAuthintests/gateway/test_bluebubbles.py. The fail-closed test FAILS without the fix(auth bypassed → request dispatched, 400 downstream) and passes with it:
Without the fix:
With the fix:
Full adapter suite:
python -m py_compileclean on both changed files.