security: require pre-configured allowedUserId, remove auto-pair

The first-DM auto-pair behavior combined with ! shell passthrough meant
the first account to DM the bot gained arbitrary shell access. This
removes that footgun entirely.

- allowedUserId must be set before polling starts; missing config blocks
  polling with a TUI warning rather than silently accepting any sender
- TELEGRAM_ALLOWED_USER_ID env var is read on session start and
  overwrites the saved config value, so rotating the allowed user is a
  restart away
- /telegram-setup now prompts for a numeric user ID after the bot token
  if one is not already configured
- Denied senders receive an auth error reply; their numeric ID is also
  logged to the pi TUI as a warning so operators can identify themselves
  on a fresh install without needing @userinfobot
- Dropped the {kind: "pair"} authorization state entirely; undefined
  allowedUserId now produces deny, not pair
- Removed pairTelegramUserIfNeeded, shouldPair, shouldNotifyPaired

Existing installs with allowedUserId already in telegram.json are
unaffected. Fresh installs require explicit configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wassname
2026-04-22 06:04:56 +08:00
parent c28436503f
commit 15fa661b7a
8 changed files with 123 additions and 88 deletions
+16
View File
@@ -16,6 +16,22 @@ export const TELEGRAM_BOT_TOKEN_ENV_VARS = [
"TELEGRAM_KEY",
] as const;
export const TELEGRAM_ALLOWED_USER_ID_ENV_VAR = "TELEGRAM_ALLOWED_USER_ID";
export function readAllowedUserIdFromEnv(
env: NodeJS.ProcessEnv = process.env,
): number | undefined {
const raw = env[TELEGRAM_ALLOWED_USER_ID_ENV_VAR]?.trim();
if (!raw) return undefined;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(
`${TELEGRAM_ALLOWED_USER_ID_ENV_VAR}="${raw}" is not a valid Telegram user ID (must be a positive integer)`,
);
}
return parsed;
}
export function getTelegramBotTokenInputDefault(
env: NodeJS.ProcessEnv = process.env,
configToken?: string,
+3 -27
View File
@@ -95,7 +95,6 @@ export interface TelegramUpdateRoutingLike {
}
export type TelegramAuthorizationState =
| { kind: "pair"; userId: number }
| { kind: "allow" }
| { kind: "deny" };
@@ -103,9 +102,6 @@ export function getTelegramAuthorizationState(
userId: number,
allowedUserId?: number,
): TelegramAuthorizationState {
if (allowedUserId === undefined) {
return { kind: "pair", userId };
}
if (userId === allowedUserId) {
return { kind: "allow" };
}
@@ -214,14 +210,11 @@ export type TelegramUpdateExecutionPlan =
| {
kind: "callback";
query: TelegramCallbackQueryLike;
shouldPair: boolean;
shouldDeny: boolean;
}
| {
kind: "message";
message: TelegramMessageLike & { from: TelegramUserLike };
shouldPair: boolean;
shouldNotifyPaired: boolean;
shouldDeny: boolean;
};
@@ -239,15 +232,12 @@ export function buildTelegramUpdateExecutionPlan(
return {
kind: "callback",
query: action.query,
shouldPair: action.authorization.kind === "pair",
shouldDeny: action.authorization.kind === "deny",
};
case "message":
return {
kind: "message",
message: action.message,
shouldPair: action.authorization.kind === "pair",
shouldNotifyPaired: action.authorization.kind === "pair",
shouldDeny: action.authorization.kind === "deny",
};
}
@@ -280,10 +270,7 @@ export interface TelegramUpdateRuntimeDeps {
>,
ctx: ExtensionContext,
) => Promise<void>;
pairTelegramUserIfNeeded: (
userId: number,
ctx: ExtensionContext,
) => Promise<boolean>;
onDeniedUserId?: (userId: number) => void;
answerCallbackQuery: (
callbackQueryId: string,
text?: string,
@@ -356,10 +343,8 @@ export async function executeTelegramUpdatePlan(
return;
}
if (plan.kind === "callback") {
if (plan.shouldPair) {
await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx);
}
if (plan.shouldDeny) {
deps.onDeniedUserId?.(plan.query.from.id);
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
if (callbackQueryId) {
await deps.answerCallbackQuery(
@@ -372,18 +357,9 @@ export async function executeTelegramUpdatePlan(
await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
return;
}
const pairedNow = plan.shouldPair
? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
: false;
const replyTarget = getTelegramMessageReplyTarget(plan.message);
if (pairedNow && plan.shouldNotifyPaired && replyTarget) {
await deps.sendTextReply(
replyTarget.chatId,
replyTarget.messageId,
"Telegram bridge paired with this account.",
);
}
if (plan.shouldDeny) {
deps.onDeniedUserId?.(plan.message.from.id);
if (replyTarget) {
await deps.sendTextReply(
replyTarget.chatId,