fix(gateway/bluebubbles): embed password in registered webhook URL for inbound auth

When BlueBubbles posts webhook events to the adapter, it uses the exact
URL registered via /api/v1/webhook — and BB's registration API does not
support custom headers. The adapter currently registers the bare URL
(no credentials), but then requires password auth on inbound POSTs,
rejecting every webhook with HTTP 401.

This is masked on fresh BB installs by a race condition: the webhook
might register once with a prior (possibly patched) URL and keep working
until the first restart. On v0.9.0, _unregister_webhook runs on clean
shutdown, so the next startup re-registers with the bare URL and the
401s begin. Users see the bot go silent with no obvious cause.

Root cause: there's no way to pass auth credentials from BB to the
webhook handler except via the URL itself. BB accepts query params and
preserves them on outbound POSTs.

## Fix

Introduce `_webhook_register_url` — the URL handed to BB's registration
API, with the configured password appended as a `?password=<value>`
query param. The existing webhook auth handler already accepts this
form (it reads `request.query.get("password")`), so no change to the
receive side is needed.

The bare `_webhook_url` is still used for logging and for binding the
local listener, so credentials don't leak into log output. Only the
registration/find/unregister paths use the password-bearing form.

## Notes

- Password is URL-encoded via urllib.parse.quote, handling special
  characters (&, *, @, etc.) that would otherwise break parsing.
- Storing the password in BB's webhook table is not a new disclosure:
  anyone with access to that table already has the BB admin password
  (same credential used for every other API call).
- If `self.password` is empty (no auth configured), the register URL
  is the bare URL — preserves current behavior for unauthenticated
  local-only setups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cypres0099 2026-04-14 10:39:51 -05:00 committed by Teknium
parent 8b52356849
commit 326cbbe40e
2 changed files with 39 additions and 2 deletions

View file

@ -224,6 +224,21 @@ class BlueBubblesAdapter(BasePlatformAdapter):
host = "localhost" host = "localhost"
return f"http://{host}:{self.webhook_port}{self.webhook_path}" return f"http://{host}:{self.webhook_port}{self.webhook_path}"
@property
def _webhook_register_url(self) -> str:
"""Webhook URL registered with BlueBubbles, including the password as
a query param so inbound webhook POSTs carry credentials.
BlueBubbles posts events to the exact URL registered via
``/api/v1/webhook``. Its webhook registration API does not support
custom headers, so embedding the password in the URL is the only
way to authenticate inbound webhooks without disabling auth.
"""
base = self._webhook_url
if self.password:
return f"{base}?password={quote(self.password, safe='')}"
return base
async def _find_registered_webhooks(self, url: str) -> list: async def _find_registered_webhooks(self, url: str) -> list:
"""Return list of BB webhook entries matching *url*.""" """Return list of BB webhook entries matching *url*."""
try: try:
@ -245,7 +260,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not self.client: if not self.client:
return False return False
webhook_url = self._webhook_url webhook_url = self._webhook_register_url
# Crash resilience — reuse an existing registration if present # Crash resilience — reuse an existing registration if present
existing = await self._find_registered_webhooks(webhook_url) existing = await self._find_registered_webhooks(webhook_url)
@ -292,7 +307,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not self.client: if not self.client:
return False return False
webhook_url = self._webhook_url webhook_url = self._webhook_register_url
removed = False removed = False
try: try:

View file

@ -442,6 +442,28 @@ class TestBlueBubblesWebhookUrl:
adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50") adapter = _make_adapter(monkeypatch, webhook_host="192.168.1.50")
assert "192.168.1.50" in adapter._webhook_url assert "192.168.1.50" in adapter._webhook_url
def test_register_url_embeds_password(self, monkeypatch):
"""_webhook_register_url should append ?password=... for inbound auth."""
adapter = _make_adapter(monkeypatch, password="secret123")
assert adapter._webhook_register_url.endswith("?password=secret123")
assert adapter._webhook_register_url.startswith(adapter._webhook_url)
def test_register_url_url_encodes_password(self, monkeypatch):
"""Passwords with special characters must be URL-encoded."""
adapter = _make_adapter(monkeypatch, password="W9fTC&L5JL*@")
assert "password=W9fTC%26L5JL%2A%40" in adapter._webhook_register_url
def test_register_url_omits_query_when_no_password(self, monkeypatch):
"""If no password is configured, the register URL should be the bare URL."""
monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False)
from gateway.platforms.bluebubbles import BlueBubblesAdapter
cfg = PlatformConfig(
enabled=True,
extra={"server_url": "http://localhost:1234", "password": ""},
)
adapter = BlueBubblesAdapter(cfg)
assert adapter._webhook_register_url == adapter._webhook_url
class TestBlueBubblesWebhookRegistration: class TestBlueBubblesWebhookRegistration:
"""Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks.""" """Tests for _register_webhook, _unregister_webhook, _find_registered_webhooks."""