Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions gateway/authz_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@
)


def _normalize_bluebubbles_handle(value: str) -> str:
"""Normalize a BlueBubbles (iMessage) handle for allowlist matching.

iMessage handles are phone numbers or Apple ID email addresses. Emails are
lowercased as-is; phone numbers are reduced to digits only, so any human
formatting (``+1 (555) 123-0001``, ``+15551230001``, ``1-555-123-0001``)
compares equal. Returns ``""`` for empty/whitespace input (callers must
not match on the empty string).
"""
v = (value or "").strip().lower()
if "@" in v:
return v
return "".join(ch for ch in v if ch.isdigit())


def _auth_env(name: str, default: str = "") -> str:
"""Read allowlist/auth env; prefer profile secret_scope under multiplex."""
if not name:
Expand Down Expand Up @@ -765,6 +780,27 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
if normalized_user_id:
check_ids.add(normalized_user_id)

# BlueBubbles (iMessage): handles are phone numbers or Apple ID email
# addresses, and operators naturally write phone numbers with human
# formatting (``+1 (555) 123-0001``) while the wire form differs in
# punctuation (``+15551230001``). Raw string equality then never
# matches, silently denying allowlisted senders and pushing operators
# toward BLUEBUBBLES_ALLOW_ALL_USERS — the opposite of the SECURITY.md
# §2.6 rule that every network-exposed adapter be gated by an
# allowlist. Compare digits-only phone forms (emails lowercased)
# alongside the original strings so no exact-match setup regresses.
if source.platform == Platform.BLUEBUBBLES:
normalized_allowed_ids = set()
for allowed_id in allowed_ids:
normalized_allowed_id = _normalize_bluebubbles_handle(allowed_id)
if normalized_allowed_id:
normalized_allowed_ids.add(normalized_allowed_id)
allowed_ids |= normalized_allowed_ids

normalized_user_id = _normalize_bluebubbles_handle(user_id)
if normalized_user_id:
check_ids.add(normalized_user_id)

# SimpleX: SIMPLEX_ALLOWED_USERS accepts either the numeric contactId
# or the contact's display name. The adapter sets user_id=contactId for
# stability across renames, but the SimpleX UI never surfaces the
Expand Down
189 changes: 189 additions & 0 deletions tests/gateway/test_bluebubbles_authz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""BLUEBUBBLES_ALLOWED_USERS must match phone-number handles across formats.

BlueBubbles (iMessage) handles are phone numbers or Apple ID email addresses.
Operators write phone numbers in human formats (``+1 (555) 123-0001``) while
the wire form differs only in punctuation (``+15551230001``), and
``_is_user_authorized`` compared them with raw string equality — so the
allowlist silently never matched and every allowlisted sender was denied.
That pushes operators toward ``BLUEBUBBLES_ALLOW_ALL_USERS``, the opposite of
SECURITY.md §2.6 ("An allowlist is required for every enabled network-exposed
adapter").

The fix normalizes BlueBubbles handles on both sides of the comparison:
emails lowercased as-is, phone numbers reduced to digits only. Original
(un-normalized) forms keep matching too, and no other platform's comparison
changes.
"""

from gateway.config import Platform
from gateway.session import SessionSource

FORMATTED = "+1 (555) 123-0001"
WIRE = "+15551230001"
DIGITS = "15551230001"
OTHER_WIRE = "+15559870002"
EMAIL = "contact@example.com"
EMAIL_MIXED_CASE = "Contact@Example.COM"

AUTH_ENV_VARS = (
"BLUEBUBBLES_ALLOWED_USERS",
"BLUEBUBBLES_ALLOW_ALL_USERS",
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
)


def _clear_auth_env(monkeypatch) -> None:
for key in AUTH_ENV_VARS:
monkeypatch.delenv(key, raising=False)


def _make_runner():
"""Bare GatewayRunner: no adapters, no pairing store — env allowlists only."""
from gateway.run import GatewayRunner

runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner.pairing_store = None
return runner


def _source(user_id: str, platform: Platform = Platform.BLUEBUBBLES) -> SessionSource:
return SessionSource(
platform=platform,
chat_id=f"iMessage;-;{user_id}" if platform == Platform.BLUEBUBBLES else "42",
chat_type="dm",
user_id=user_id,
)


# ------------------------------------------------------------- phone matching


def test_formatted_env_entry_matches_wire_handle(monkeypatch):
"""Human-formatted allowlist entry matches the punctuation-free wire form."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", FORMATTED)

assert _make_runner()._is_user_authorized(_source(WIRE)) is True


def test_wire_env_entry_matches_formatted_handle(monkeypatch):
"""Symmetric case: wire-form allowlist entry matches a formatted handle."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", WIRE)

assert _make_runner()._is_user_authorized(_source(FORMATTED)) is True


def test_digits_only_env_entry_matches_plus_prefixed_wire_handle(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", DIGITS)

assert _make_runner()._is_user_authorized(_source(WIRE)) is True


def test_exact_raw_match_still_works(monkeypatch):
"""Regression guard: an already-exact entry keeps matching."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", WIRE)

assert _make_runner()._is_user_authorized(_source(WIRE)) is True


def test_global_allowlist_entry_is_normalized_for_bluebubbles(monkeypatch):
"""GATEWAY_ALLOWED_USERS entries get the same treatment for BlueBubbles."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("GATEWAY_ALLOWED_USERS", FORMATTED)

assert _make_runner()._is_user_authorized(_source(WIRE)) is True


# ------------------------------------------------------------- email matching


def test_email_matches_case_insensitively(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", EMAIL_MIXED_CASE)

assert _make_runner()._is_user_authorized(_source(EMAIL)) is True


def test_email_matches_case_insensitively_reversed(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", EMAIL)

assert _make_runner()._is_user_authorized(_source(EMAIL_MIXED_CASE)) is True


# ---------------------------------------------------------------- still denies


def test_unlisted_sender_still_denied(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", FORMATTED)

assert _make_runner()._is_user_authorized(_source(OTHER_WIRE)) is False


def test_unlisted_email_still_denied(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", EMAIL)

assert _make_runner()._is_user_authorized(_source("other@example.com")) is False


def test_no_allowlist_still_default_denies(monkeypatch):
"""SECURITY.md §2.6: no allowlist configured means deny, not allow."""
_clear_auth_env(monkeypatch)

assert _make_runner()._is_user_authorized(_source(WIRE)) is False


def test_empty_normalized_forms_do_not_cross_match(monkeypatch):
"""A digit-free entry must not normalize to '' and match a digit-free sender."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", "---")

assert _make_runner()._is_user_authorized(_source("()")) is False


# ------------------------------------------------------------ allow-all paths


def test_wildcard_entry_still_allows_everyone(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOWED_USERS", "*")

assert _make_runner()._is_user_authorized(_source(OTHER_WIRE)) is True


def test_allow_all_flag_still_works(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("BLUEBUBBLES_ALLOW_ALL_USERS", "true")

assert _make_runner()._is_user_authorized(_source(OTHER_WIRE)) is True


# ----------------------------------------------------- other platforms intact


def test_other_platforms_are_not_phone_normalized(monkeypatch):
"""A formatted Telegram entry must NOT digit-match a bare numeric user id."""
_clear_auth_env(monkeypatch)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", FORMATTED)

runner = _make_runner()
assert runner._is_user_authorized(_source(DIGITS, platform=Platform.TELEGRAM)) is False
assert runner._is_user_authorized(_source(WIRE, platform=Platform.TELEGRAM)) is False


def test_telegram_exact_numeric_id_still_matches(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "111222333")

runner = _make_runner()
assert runner._is_user_authorized(_source("111222333", platform=Platform.TELEGRAM)) is True
assert runner._is_user_authorized(_source("999888777", platform=Platform.TELEGRAM)) is False