fix(telegram): reject unauthorized users before event construction (#40863)

Removed/unauthorized Telegram users could inject prompt content before the
per-user auth gate fired. The adapter ran `_should_process_message`,
`_build_message_event`, and text/photo batching — and dispatched to the
runner — before `_is_user_authorized()` (gateway/authz_mixin.py) rejected
the sender. Unmentioned group chatter from a removed user was also
persisted into the session transcript via `_observe_unmentioned_group_message`,
leaking into the agent's observed context independent of dispatch.

Add `_is_user_authorized_from_message()` as an intake prefilter that runs
in `_handle_text_message`, `_handle_command`, `_handle_location_message`,
and `_handle_media_message` BEFORE batching, event construction, and the
unmentioned-group observe branch. It reuses the runner's
`_is_user_authorized()` with a correctly-shaped SessionSource (group vs
forum vs dm, real chat_id for TELEGRAM_GROUP_ALLOWED_* allowlists),
falls back to env allowlists, and only rejects when an allowlist actually
exists — unknown DMs with no allowlist still reach the pairing flow.
Channel posts authorize via `sender_chat` identity when `from_user` is
absent.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Carlos Manuel Cejas <carlosmcejas@gmail.com>
This commit is contained in:
teknium1 2026-06-28 02:54:24 -07:00 committed by Teknium
parent 61210097a5
commit c648ecdca5
3 changed files with 569 additions and 0 deletions

View file

@ -559,6 +559,146 @@ class TelegramAdapter(BasePlatformAdapter):
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
return "*" in allowed_ids or normalized_user_id in allowed_ids
def _source_from_message_for_auth(self, message: Message):
"""Build the same Telegram source shape the gateway auth path expects.
Resolves the identity to authorize from ``from_user`` for normal
messages, falling back to ``sender_chat`` for channel posts (which
carry no ``from_user``) so a removed/unauthorized channel cannot
inject content via the broadcast path either.
"""
from gateway.session import SessionSource
user = getattr(message, "from_user", None)
chat = getattr(message, "chat", None)
user_id = str(getattr(user, "id", "")).strip() or None
user_name = (
str(getattr(user, "username", "") or getattr(user, "full_name", "") or "").strip()
or None
)
# Channel posts have no from_user — authorize the sender chat instead.
if not user_id:
sender_chat = getattr(message, "sender_chat", None)
if sender_chat is not None:
user_id = str(getattr(sender_chat, "id", "")).strip() or None
if not user_name:
user_name = (
str(getattr(sender_chat, "title", "") or "").strip() or None
)
chat_id = str(getattr(chat, "id", "")).strip() or user_id
chat_type = str(getattr(chat, "type", "dm")).strip().lower() or "dm"
if chat_type == "private":
chat_type = "dm"
elif chat_type == "supergroup":
thread_id_raw = getattr(message, "message_thread_id", None)
is_topic_message = bool(getattr(message, "is_topic_message", False))
is_forum_group = getattr(chat, "is_forum", False) is True
chat_type = (
"forum"
if thread_id_raw is not None and (is_topic_message or is_forum_group)
else "group"
)
thread_id = None
thread_id_raw = getattr(message, "message_thread_id", None)
if thread_id_raw is not None:
is_topic_message = bool(getattr(message, "is_topic_message", False))
is_forum_group = getattr(chat, "is_forum", False) is True
if chat_type == "forum" and (is_topic_message or is_forum_group):
thread_id = str(thread_id_raw)
elif chat_type == "dm" and is_topic_message:
thread_id = str(thread_id_raw)
return SessionSource(
platform=Platform.TELEGRAM,
chat_id=chat_id or "",
chat_type=chat_type,
user_id=user_id,
user_name=user_name,
thread_id=thread_id,
)
def _telegram_auth_env_configured(self) -> bool:
"""Return True when Telegram auth env vars make an early decision safe."""
keys = (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
"TELEGRAM_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
)
return any(os.getenv(key, "").strip() for key in keys)
def _is_user_authorized_from_message(self, message: Message) -> bool:
"""Check if the sender of a Telegram message is authorized.
Intake prefilter that runs BEFORE text batching, event construction,
and unmentioned-group observation, so a removed/unauthorized user
cannot inject prompt content into the agent path or the observed
transcript (fixes #40863). It only rejects when it can make the same
context-aware decision the runner would make. Unknown DMs with no
allowlist still pass through so the normal pairing flow can run.
"""
source = self._source_from_message_for_auth(message)
user_id = source.user_id
# No identity at all → genuine group service message (pin, delete,
# new_chat_members, etc.). Defer to the cold path. Channel posts
# without sender_chat already resolved to None above and fall here;
# they carry no authorizable identity, so let the normal
# _should_process_message gating handle them.
if not user_id:
return True
# Adapter-level allow_from: when set, it is the sole authority.
adapter_allow_from = self.config.extra.get("allow_from")
if adapter_allow_from is not None:
allowed = {str(u).strip() for u in adapter_allow_from if str(u).strip()}
return user_id in allowed or "*" in allowed
# Test/custom injection only. The class method named
# _is_callback_user_authorized is for inline button callbacks and must
# not be treated as a user-id-only shortcut for real messages — only
# honor an instance-level override (set in tests).
callback_auth = self.__dict__.get("_is_callback_user_authorized")
if callable(callback_auth):
try:
return bool(
callback_auth(
user_id,
chat_id=source.chat_id,
chat_type=source.chat_type,
thread_id=source.thread_id,
user_name=source.user_name,
)
)
except Exception:
pass
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
auth_fn = getattr(runner, "_is_user_authorized", None)
if callable(auth_fn):
# Only make an early decision via the runner when an allowlist
# actually exists; otherwise unknown DMs must reach the pairing
# flow rather than being default-denied here.
if not self._telegram_auth_env_configured():
return True
try:
return bool(auth_fn(source))
except Exception:
logger.debug(
"[Telegram] Falling back to env-only auth for user %s",
user_id,
exc_info=True,
)
allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
if not allowed_csv:
return True
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
return "*" in allowed_ids or user_id in allowed_ids
@classmethod
def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
if not metadata:
@ -6567,6 +6707,17 @@ class TelegramAdapter(BasePlatformAdapter):
msg = self._effective_update_message(update)
if not msg or not msg.text:
return
# Early user-level auth check: reject unauthorized users before any
# text batching, observe-buffer persistence, event building, or response
# generation. This prevents removed/blocked users from injecting prompts
# into the agent path or the observed transcript context (#40863).
if not self._is_user_authorized_from_message(msg):
logger.warning(
"[Telegram] Blocked unauthorized user %s in chat %s",
getattr(getattr(msg, "from_user", None), "id", None),
getattr(getattr(msg, "chat", None), "id", None),
)
return
if not self._should_process_message(msg):
if self._should_observe_unmentioned_group_message(msg):
self._observe_unmentioned_group_message(msg, MessageType.TEXT, update_id=update.update_id)
@ -6586,6 +6737,13 @@ class TelegramAdapter(BasePlatformAdapter):
return
if not self._should_process_message(msg, is_command=True):
return
if not self._is_user_authorized_from_message(msg):
logger.warning(
"[Telegram] Blocked unauthorized user %s in chat %s",
getattr(getattr(msg, "from_user", None), "id", None),
getattr(getattr(msg, "chat", None), "id", None),
)
return
await self._ensure_forum_commands(msg)
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
@ -6599,6 +6757,13 @@ class TelegramAdapter(BasePlatformAdapter):
msg = self._effective_update_message(update)
if not msg:
return
if not self._is_user_authorized_from_message(msg):
logger.warning(
"[Telegram] Blocked unauthorized user %s in chat %s",
getattr(getattr(msg, "from_user", None), "id", None),
getattr(getattr(msg, "chat", None), "id", None),
)
return
if not self._should_process_message(msg):
if self._should_observe_unmentioned_group_message(msg):
self._observe_unmentioned_group_message(msg, MessageType.LOCATION, update_id=update.update_id)
@ -6781,6 +6946,13 @@ class TelegramAdapter(BasePlatformAdapter):
"""Handle incoming media messages, downloading images to local cache."""
if not update.message:
return
if not self._is_user_authorized_from_message(update.message):
logger.info(
"[Telegram] Blocked media from unauthorized user %s in chat %s",
getattr(getattr(update.message, "from_user", None), "id", None),
getattr(getattr(update.message, "chat", None), "id", None),
)
return
if not self._should_process_message(update.message):
if self._should_observe_unmentioned_group_message(update.message):
_m = update.message