From eecfebd41921884a0218131947a9751847f980f8 Mon Sep 17 00:00:00 2001 From: jay <1569941651@qq.com> Date: Wed, 14 Oct 2026 07:55:06 +0800 Subject: [PATCH] fix(buzz): treat a scalar allowed_users entry as one member pubkey (#6811) * fix(buzz): treat a scalar allowed_users entry as one member pubkey BuzzChannel built its allowlist by iterating config.get("allowed_users") directly. A scalar string -- what an allowed_users: $BUZZ_OWNER_PUBKEY reference resolves to -- was iterated character by character, feeding each character to parse_pubkey, whose first non-hex character raised and aborted channel construction; a numeric scalar raised TypeError the same way. The sibling QQ (#6730) and Discord (#6776) connectors already treat a scalar as a single entry. Parse the value with the shared parse_allowed_users helper via a parse_pubkey coercer: a scalar is one entry, list entries keep their per-entry validation, unusable entries (bad hex, numeric refs, mappings) are dropped with the shared warning instead of crashing, and the deny-by-default contract is unchanged -- an empty or blank value still yields an empty allowlist with start()'s existing deny-all warning. config.example.yaml states the accepted shape next to the key, matching the mention_free_channels note added by #6776. * docs(config): split buzz allowed_users note into aligned comment lines The two-line note on the buzz example entry was emitted with a literal `/n` where a newline was intended, so the second note never became its own comment line and the `#`-column alignment shared with the DENY-BY-DEFAULT lines below was broken. Split it to match the mention_free_channels sibling shape (review comment on #6811). --- backend/app/channels/buzz.py | 28 ++++++++++++++++++++-- backend/tests/test_buzz_channel.py | 37 ++++++++++++++++++++++++++++++ config.example.yaml | 3 ++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/backend/app/channels/buzz.py b/backend/app/channels/buzz.py index bc825f124b..416217534f 100644 --- a/backend/app/channels/buzz.py +++ b/backend/app/channels/buzz.py @@ -49,7 +49,7 @@ from typing import Any from urllib.parse import urlparse from app.channels import buzz_nostr -from app.channels.allowed_users import parse_channel_ids +from app.channels.allowed_users import parse_allowed_users, parse_channel_ids from app.channels.base import Channel from app.channels.buzz_seen_events import BuzzSeenEventStore from app.channels.commands import is_known_channel_command @@ -250,6 +250,19 @@ def _is_transient_close(reason: str) -> bool: return not any(marker in text for marker in _PERMANENT_CLOSE_MARKERS) +def _parse_member_pubkey(value: Any) -> str | None: + """Return the member's hex pubkey, or None when the entry is not one. + + Shared parse_allowed_users coerces config entries through this, so a + scalar pubkey is one member and an unusable entry is dropped with a + warning instead of aborting channel construction. + """ + try: + return buzz_nostr.parse_pubkey(str(value)) + except ValueError: + return None + + def _parse_mention_free_channels(mention_free_channels: Any) -> frozenset[str]: """Parse Buzz mention exemptions without splitting scalar channel IDs.""" return parse_channel_ids( @@ -277,7 +290,18 @@ class BuzzChannel(Channel): self._workspace_id = urlparse(self._relay_url).netloc self._private_key_raw = str(config.get("private_key", "")) self._keys: buzz_nostr.NostrKeys | None = None # parsed in start() so coincurve stays lazy - self._allowed_users = {buzz_nostr.parse_pubkey(v) for v in config.get("allowed_users", []) or []} + self._allowed_users = ( + parse_allowed_users( + config.get("allowed_users"), + parse_user_id=_parse_member_pubkey, + logger=logger, + channel_name="buzz", + expected_id="a 64-hex or npub1... member pubkey", + valid_id_name="member pubkey", + warn_on_scalar_separators=True, + ) + or frozenset() + ) self._require_mention = bool(config.get("require_mention", True)) self._mention_free: frozenset[str] = _parse_mention_free_channels(config.get("mention_free_channels")) self._channel_meta: dict[str, dict[str, Any]] = {} diff --git a/backend/tests/test_buzz_channel.py b/backend/tests/test_buzz_channel.py index b22df0b7ba..8ea20d79cf 100644 --- a/backend/tests/test_buzz_channel.py +++ b/backend/tests/test_buzz_channel.py @@ -98,6 +98,43 @@ def test_config_mention_free_channel_scalar_with_separators_warns(caplog): assert "treating it as one literal channel id" in caplog.text +def test_config_allowed_users_accepts_a_single_scalar_pubkey(): + """One pubkey scalar is one member entry -- not the characters of it. + + An ``allowed_users: $BUZZ_OWNER_PUBKEY`` style reference resolves to a + scalar string. Iterating it character by character fed each character to + ``parse_pubkey``, whose first non-hex character raised and aborted channel + construction. Sibling channels (QQ #6730, Discord) already treat a scalar + as a single entry. + """ + assert _channel(allowed_users=OWNER)._allowed_users == frozenset({OWNER}) + assert _channel(allowed_users=f" {OWNER} ")._allowed_users == frozenset({OWNER}) + + +def test_config_allowed_users_accepts_a_non_string_scalar_without_crashing(): + """An unquoted scalar YAML typed as a number must not abort channel setup. + + An int is not iterable, so constructing the channel raised + ``TypeError: 'int' object is not iterable``; the value is not a usable + pubkey, so the deny-by-default allowlist stays empty. + """ + assert _channel(allowed_users=123)._allowed_users == frozenset() + + +def test_config_allowed_users_drops_invalid_entries_with_a_warning(caplog): + """A mix of valid and unusable entries keeps the valid ones, fail-closed.""" + with caplog.at_level(logging.WARNING, logger="app.channels.buzz"): + ch = _channel(allowed_users=[OWNER, "not-a-pubkey", 42]) + assert ch._allowed_users == frozenset({OWNER}) + assert "Ignoring allowed_users entry" in caplog.text + + +def test_config_allowed_users_empty_shapes_still_deny_by_default(): + """Unset, blank and empty shapes keep the empty allowlist (deny all).""" + for value in (None, "", " ", [], frozenset()): + assert _channel(allowed_users=value)._allowed_users == frozenset() + + def test_config_rejects_non_websocket_relay_url(): with pytest.raises(ValueError): _channel(relay_url="https://buzz.example.com") diff --git a/config.example.yaml b/config.example.yaml index f0a6b0facc..b9997703d0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3207,7 +3207,8 @@ run_ownership: # enabled: false # relay_url: wss://buzz.example.com # private_key: $BUZZ_PRIVATE_KEY # hex or nsec1… -# allowed_users: [] # pubkeys (hex or npub) allowed to trigger runs. +# allowed_users: [] # pubkeys (hex or npub) allowed to trigger runs: +# # one pubkey (hex or npub) or a YAML list. # # DENY-BY-DEFAULT (unlike other channels): empty # # means nobody, and DeerFlow logs a startup warning. # require_mention: true # @mention needed in channels