VYPR
Moderate severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026

OpenClaw < 2026.2.14 - Identity Spoofing via Mutable Username in Telegram Allowlist Authorization

CVE-2026-28480

Description

OpenClaw versions prior to 2026.2.14 contain an authorization bypass vulnerability where Telegram allowlist matching accepts mutable usernames instead of immutable numeric sender IDs. Attackers can spoof identity by obtaining recycled usernames to bypass allowlist restrictions and interact with bots as unauthorized senders.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.142026.2.14
clawdbotnpm
<= 2026.1.24-3

Affected products

1

Patches

2
9e147f00b48e

fix(doctor): resolve telegram allowFrom usernames

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
4 files changed · +375 5
  • CHANGELOG.md+1 1 modified
    @@ -26,7 +26,7 @@ Docs: https://docs.openclaw.ai
     ### Fixes
     
     - Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
    -- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals) and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
    +- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
     - Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
     - Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
     - Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
    
  • src/commands/doctor-config-flow.e2e.test.ts+81 1 modified
    @@ -1,6 +1,6 @@
     import fs from "node:fs/promises";
     import path from "node:path";
    -import { describe, expect, it } from "vitest";
    +import { describe, expect, it, vi } from "vitest";
     import { withTempHome } from "../../test/helpers/temp-home.js";
     import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
     
    @@ -64,4 +64,84 @@ describe("doctor config flow", () => {
           });
         });
       });
    +
    +  it("resolves Telegram @username allowFrom entries to numeric IDs on repair", async () => {
    +    const fetchSpy = vi.fn(async (url: string) => {
    +      const u = String(url);
    +      const chatId = new URL(u).searchParams.get("chat_id") ?? "";
    +      const id =
    +        chatId.toLowerCase() === "@testuser"
    +          ? 111
    +          : chatId.toLowerCase() === "@groupuser"
    +            ? 222
    +            : chatId.toLowerCase() === "@topicuser"
    +              ? 333
    +              : chatId.toLowerCase() === "@accountuser"
    +                ? 444
    +                : null;
    +      return {
    +        ok: id != null,
    +        json: async () => (id != null ? { ok: true, result: { id } } : { ok: false }),
    +      } as unknown as Response;
    +    });
    +    vi.stubGlobal("fetch", fetchSpy);
    +    try {
    +      await withTempHome(async (home) => {
    +        const configDir = path.join(home, ".openclaw");
    +        await fs.mkdir(configDir, { recursive: true });
    +        await fs.writeFile(
    +          path.join(configDir, "openclaw.json"),
    +          JSON.stringify(
    +            {
    +              channels: {
    +                telegram: {
    +                  botToken: "123:abc",
    +                  allowFrom: ["@testuser"],
    +                  groupAllowFrom: ["groupUser"],
    +                  groups: {
    +                    "-100123": {
    +                      allowFrom: ["tg:@topicUser"],
    +                      topics: { "99": { allowFrom: ["@accountUser"] } },
    +                    },
    +                  },
    +                  accounts: {
    +                    alerts: { botToken: "456:def", allowFrom: ["@accountUser"] },
    +                  },
    +                },
    +              },
    +            },
    +            null,
    +            2,
    +          ),
    +          "utf-8",
    +        );
    +
    +        const result = await loadAndMaybeMigrateDoctorConfig({
    +          options: { nonInteractive: true, repair: true },
    +          confirm: async () => false,
    +        });
    +
    +        const cfg = result.cfg as unknown as {
    +          channels: {
    +            telegram: {
    +              allowFrom: string[];
    +              groupAllowFrom: string[];
    +              groups: Record<
    +                string,
    +                { allowFrom: string[]; topics: Record<string, { allowFrom: string[] }> }
    +              >;
    +              accounts: Record<string, { allowFrom: string[] }>;
    +            };
    +          };
    +        };
    +        expect(cfg.channels.telegram.allowFrom).toEqual(["111"]);
    +        expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]);
    +        expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]);
    +        expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]);
    +        expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]);
    +      });
    +    } finally {
    +      vi.unstubAllGlobals();
    +    }
    +  });
     });
    
  • src/commands/doctor-config-flow.ts+289 0 modified
    @@ -11,6 +11,7 @@ import {
       readConfigFileSnapshot,
     } from "../config/config.js";
     import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
    +import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
     import { note } from "../terminal/note.js";
     import { isRecord, resolveHomeDir } from "../utils.js";
     import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
    @@ -142,6 +143,273 @@ function noteOpencodeProviderOverrides(cfg: OpenClawConfig) {
       note(lines.join("\n"), "OpenCode Zen");
     }
     
    +function normalizeTelegramAllowFromEntry(raw: unknown): string {
    +  const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : "";
    +  return base
    +    .trim()
    +    .replace(/^(telegram|tg):/i, "")
    +    .trim();
    +}
    +
    +function isNumericTelegramUserId(raw: string): boolean {
    +  return /^\d+$/.test(raw);
    +}
    +
    +type TelegramAllowFromUsernameHit = { path: string; entry: string };
    +
    +function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] {
    +  const hits: TelegramAllowFromUsernameHit[] = [];
    +  const telegram = cfg.channels?.telegram;
    +  if (!telegram) {
    +    return hits;
    +  }
    +
    +  const scanList = (pathLabel: string, list: unknown) => {
    +    if (!Array.isArray(list)) {
    +      return;
    +    }
    +    for (const entry of list) {
    +      const normalized = normalizeTelegramAllowFromEntry(entry);
    +      if (!normalized || normalized === "*") {
    +        continue;
    +      }
    +      if (isNumericTelegramUserId(normalized)) {
    +        continue;
    +      }
    +      hits.push({ path: pathLabel, entry: String(entry).trim() });
    +    }
    +  };
    +
    +  const scanAccount = (prefix: string, account: Record<string, unknown>) => {
    +    scanList(`${prefix}.allowFrom`, account.allowFrom);
    +    scanList(`${prefix}.groupAllowFrom`, account.groupAllowFrom);
    +    const groups = account.groups;
    +    if (!groups || typeof groups !== "object" || Array.isArray(groups)) {
    +      return;
    +    }
    +    const groupsRecord = groups as Record<string, unknown>;
    +    for (const groupId of Object.keys(groupsRecord)) {
    +      const group = groupsRecord[groupId];
    +      if (!group || typeof group !== "object" || Array.isArray(group)) {
    +        continue;
    +      }
    +      const groupRec = group as Record<string, unknown>;
    +      scanList(`${prefix}.groups.${groupId}.allowFrom`, groupRec.allowFrom);
    +      const topics = groupRec.topics;
    +      if (!topics || typeof topics !== "object" || Array.isArray(topics)) {
    +        continue;
    +      }
    +      const topicsRecord = topics as Record<string, unknown>;
    +      for (const topicId of Object.keys(topicsRecord)) {
    +        const topic = topicsRecord[topicId];
    +        if (!topic || typeof topic !== "object" || Array.isArray(topic)) {
    +          continue;
    +        }
    +        scanList(
    +          `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`,
    +          (topic as Record<string, unknown>).allowFrom,
    +        );
    +      }
    +    }
    +  };
    +
    +  scanAccount("channels.telegram", telegram as unknown as Record<string, unknown>);
    +
    +  const accounts = telegram.accounts;
    +  if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) {
    +    return hits;
    +  }
    +  for (const key of Object.keys(accounts)) {
    +    const account = accounts[key];
    +    if (!account || typeof account !== "object" || Array.isArray(account)) {
    +      continue;
    +    }
    +    scanAccount(`channels.telegram.accounts.${key}`, account as Record<string, unknown>);
    +  }
    +
    +  return hits;
    +}
    +
    +async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{
    +  config: OpenClawConfig;
    +  changes: string[];
    +}> {
    +  const hits = scanTelegramAllowFromUsernameEntries(cfg);
    +  if (hits.length === 0) {
    +    return { config: cfg, changes: [] };
    +  }
    +
    +  const tokens = Array.from(
    +    new Set(
    +      listTelegramAccountIds(cfg)
    +        .map((accountId) => resolveTelegramAccount({ cfg, accountId }))
    +        .map((account) => (account.tokenSource === "none" ? "" : account.token))
    +        .map((token) => token.trim())
    +        .filter(Boolean),
    +    ),
    +  );
    +
    +  if (tokens.length === 0) {
    +    return {
    +      config: cfg,
    +      changes: [
    +        `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`,
    +      ],
    +    };
    +  }
    +
    +  const resolveUserId = async (raw: string): Promise<string | null> => {
    +    const trimmed = raw.trim();
    +    if (!trimmed) {
    +      return null;
    +    }
    +    const stripped = normalizeTelegramAllowFromEntry(trimmed);
    +    if (!stripped || stripped === "*") {
    +      return null;
    +    }
    +    if (isNumericTelegramUserId(stripped)) {
    +      return stripped;
    +    }
    +    if (/\s/.test(stripped)) {
    +      return null;
    +    }
    +    const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
    +    for (const token of tokens) {
    +      const controller = new AbortController();
    +      const timeout = setTimeout(() => controller.abort(), 4000);
    +      try {
    +        const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
    +        const res = await fetch(url, { signal: controller.signal }).catch(() => null);
    +        if (!res || !res.ok) {
    +          continue;
    +        }
    +        const data = (await res.json().catch(() => null)) as {
    +          ok?: boolean;
    +          result?: { id?: number | string };
    +        } | null;
    +        const id = data?.ok ? data?.result?.id : undefined;
    +        if (typeof id === "number" || typeof id === "string") {
    +          return String(id);
    +        }
    +      } catch {
    +        // ignore and try next token
    +      } finally {
    +        clearTimeout(timeout);
    +      }
    +    }
    +    return null;
    +  };
    +
    +  const changes: string[] = [];
    +  const next = structuredClone(cfg);
    +
    +  const repairList = async (pathLabel: string, holder: Record<string, unknown>, key: string) => {
    +    const raw = holder[key];
    +    if (!Array.isArray(raw)) {
    +      return;
    +    }
    +    const out: Array<string | number> = [];
    +    const replaced: Array<{ from: string; to: string }> = [];
    +    for (const entry of raw) {
    +      const normalized = normalizeTelegramAllowFromEntry(entry);
    +      if (!normalized) {
    +        continue;
    +      }
    +      if (normalized === "*") {
    +        out.push("*");
    +        continue;
    +      }
    +      if (isNumericTelegramUserId(normalized)) {
    +        out.push(normalized);
    +        continue;
    +      }
    +      const resolved = await resolveUserId(String(entry));
    +      if (resolved) {
    +        out.push(resolved);
    +        replaced.push({ from: String(entry).trim(), to: resolved });
    +      } else {
    +        out.push(String(entry).trim());
    +      }
    +    }
    +    const deduped: Array<string | number> = [];
    +    const seen = new Set<string>();
    +    for (const entry of out) {
    +      const k = String(entry).trim();
    +      if (!k || seen.has(k)) {
    +        continue;
    +      }
    +      seen.add(k);
    +      deduped.push(entry);
    +    }
    +    holder[key] = deduped;
    +    if (replaced.length > 0) {
    +      for (const rep of replaced.slice(0, 5)) {
    +        changes.push(`- ${pathLabel}: resolved ${rep.from} -> ${rep.to}`);
    +      }
    +      if (replaced.length > 5) {
    +        changes.push(`- ${pathLabel}: resolved ${replaced.length - 5} more @username entries`);
    +      }
    +    }
    +  };
    +
    +  const repairAccount = async (prefix: string, account: Record<string, unknown>) => {
    +    await repairList(`${prefix}.allowFrom`, account, "allowFrom");
    +    await repairList(`${prefix}.groupAllowFrom`, account, "groupAllowFrom");
    +    const groups = account.groups;
    +    if (!groups || typeof groups !== "object" || Array.isArray(groups)) {
    +      return;
    +    }
    +    const groupsRecord = groups as Record<string, unknown>;
    +    for (const groupId of Object.keys(groupsRecord)) {
    +      const group = groupsRecord[groupId];
    +      if (!group || typeof group !== "object" || Array.isArray(group)) {
    +        continue;
    +      }
    +      const groupRec = group as Record<string, unknown>;
    +      await repairList(`${prefix}.groups.${groupId}.allowFrom`, groupRec, "allowFrom");
    +      const topics = groupRec.topics;
    +      if (!topics || typeof topics !== "object" || Array.isArray(topics)) {
    +        continue;
    +      }
    +      const topicsRecord = topics as Record<string, unknown>;
    +      for (const topicId of Object.keys(topicsRecord)) {
    +        const topic = topicsRecord[topicId];
    +        if (!topic || typeof topic !== "object" || Array.isArray(topic)) {
    +          continue;
    +        }
    +        await repairList(
    +          `${prefix}.groups.${groupId}.topics.${topicId}.allowFrom`,
    +          topic as Record<string, unknown>,
    +          "allowFrom",
    +        );
    +      }
    +    }
    +  };
    +
    +  const telegram = next.channels?.telegram;
    +  if (telegram && typeof telegram === "object" && !Array.isArray(telegram)) {
    +    await repairAccount("channels.telegram", telegram as unknown as Record<string, unknown>);
    +    const accounts = (telegram as Record<string, unknown>).accounts;
    +    if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
    +      for (const key of Object.keys(accounts as Record<string, unknown>)) {
    +        const account = (accounts as Record<string, unknown>)[key];
    +        if (!account || typeof account !== "object" || Array.isArray(account)) {
    +          continue;
    +        }
    +        await repairAccount(
    +          `channels.telegram.accounts.${key}`,
    +          account as Record<string, unknown>,
    +        );
    +      }
    +    }
    +  }
    +
    +  if (changes.length === 0) {
    +    return { config: cfg, changes: [] };
    +  }
    +  return { config: next, changes };
    +}
    +
     async function maybeMigrateLegacyConfig(): Promise<string[]> {
       const changes: string[] = [];
       const home = resolveHomeDir();
    @@ -271,6 +539,27 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
         }
       }
     
    +  if (shouldRepair) {
    +    const repair = await maybeRepairTelegramAllowFromUsernames(candidate);
    +    if (repair.changes.length > 0) {
    +      note(repair.changes.join("\n"), "Doctor changes");
    +      candidate = repair.config;
    +      pendingChanges = true;
    +      cfg = repair.config;
    +    }
    +  } else {
    +    const hits = scanTelegramAllowFromUsernameEntries(candidate);
    +    if (hits.length > 0) {
    +      note(
    +        [
    +          `- Telegram allowFrom contains ${hits.length} non-numeric entries (e.g. ${hits[0]?.entry ?? "@"}); Telegram authorization requires numeric sender IDs.`,
    +          `- Run "${formatCliCommand("openclaw doctor --fix")}" to auto-resolve @username entries to numeric IDs (requires a Telegram bot token).`,
    +        ].join("\n"),
    +        "Doctor warnings",
    +      );
    +    }
    +  }
    +
       const unknown = stripUnknownConfigKeys(candidate);
       if (unknown.removed.length > 0) {
         const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
    
  • src/config/types.telegram.ts+4 3 modified
    @@ -70,8 +70,9 @@ export type TelegramAccountConfig = {
       /** Control reply threading when reply tags are present (off|first|all). */
       replyToMode?: ReplyToMode;
       groups?: Record<string, TelegramGroupConfig>;
    +  /** DM allowlist (numeric Telegram user IDs). Onboarding can resolve @username to IDs. */
       allowFrom?: Array<string | number>;
    -  /** Optional allowlist for Telegram group senders (user ids or usernames). */
    +  /** Optional allowlist for Telegram group senders (numeric Telegram user IDs). */
       groupAllowFrom?: Array<string | number>;
       /**
        * Controls how group messages are handled:
    @@ -150,7 +151,7 @@ export type TelegramTopicConfig = {
       skills?: string[];
       /** If false, disable the bot for this topic. */
       enabled?: boolean;
    -  /** Optional allowlist for topic senders (ids or usernames). */
    +  /** Optional allowlist for topic senders (numeric Telegram user IDs). */
       allowFrom?: Array<string | number>;
       /** Optional system prompt snippet for this topic. */
       systemPrompt?: string;
    @@ -169,7 +170,7 @@ export type TelegramGroupConfig = {
       topics?: Record<string, TelegramTopicConfig>;
       /** If false, disable the bot for this group (and its topics). */
       enabled?: boolean;
    -  /** Optional allowlist for group senders (ids or usernames). */
    +  /** Optional allowlist for group senders (numeric Telegram user IDs). */
       allowFrom?: Array<string | number>;
       /** Optional system prompt snippet for this group. */
       systemPrompt?: string;
    
e3b432e481a9

fix(telegram): require sender ids for allowlist auth

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
10 files changed · +170 33
  • CHANGELOG.md+1 0 modified
    @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals) and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
     - Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
     - Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
     - Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
    
  • docs/channels/groups.md+1 1 modified
    @@ -138,7 +138,7 @@ Control how group/room messages are handled per channel:
         },
         telegram: {
           groupPolicy: "disabled",
    -      groupAllowFrom: ["123456789", "@username"],
    +      groupAllowFrom: ["123456789"], // numeric Telegram user id (wizard can resolve @username)
         },
         signal: {
           groupPolicy: "disabled",
    
  • docs/channels/telegram.md+4 3 modified
    @@ -112,7 +112,8 @@ Token resolution order is account-aware. In practice, config values win over env
         - `open` (requires `allowFrom` to include `"*"`)
         - `disabled`
     
    -    `channels.telegram.allowFrom` accepts numeric IDs and usernames. `telegram:` / `tg:` prefixes are accepted and normalized.
    +    `channels.telegram.allowFrom` accepts numeric Telegram user IDs. `telegram:` / `tg:` prefixes are accepted and normalized.
    +    The onboarding wizard accepts `@username` input and resolves it to numeric IDs.
     
         ### Finding your Telegram user ID
     
    @@ -681,9 +682,9 @@ Primary reference:
     - `channels.telegram.botToken`: bot token (BotFather).
     - `channels.telegram.tokenFile`: read token from file path.
     - `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
    -- `channels.telegram.allowFrom`: DM allowlist (ids/usernames). `open` requires `"*"`.
    +- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `open` requires `"*"`.
     - `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
    -- `channels.telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
    +- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs).
     - `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
       - `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
       - `channels.telegram.groups.<id>.requireMention`: mention gating default.
    
  • docs/help/faq.md+3 1 modified
    @@ -794,7 +794,9 @@ without WhatsApp/Telegram.
     
     ### Telegram what goes in allowFrom
     
    -`channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric, recommended) or `@username`. It is not the bot username.
    +`channels.telegram.allowFrom` is **the human sender's Telegram user ID** (numeric). It is not the bot username.
    +
    +The onboarding wizard accepts `@username` input and resolves it to a numeric ID, but OpenClaw authorization uses numeric IDs only.
     
     Safer (no third-party bot):
     
    
  • src/channels/plugins/onboarding/telegram.ts+1 1 modified
    @@ -115,7 +115,7 @@ async function promptTelegramAllowFrom(params: {
       let resolvedIds: string[] = [];
       while (resolvedIds.length === 0) {
         const entry = await prompter.text({
    -      message: "Telegram allowFrom (username or user id)",
    +      message: "Telegram allowFrom (numeric sender id; @username resolves to id)",
           placeholder: "@username",
           initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
           validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
    
  • src/security/audit-channel.ts+78 1 modified
    @@ -14,6 +14,18 @@ function normalizeAllowFromList(list: Array<string | number> | undefined | null)
       return list.map((v) => String(v).trim()).filter(Boolean);
     }
     
    +function normalizeTelegramAllowFromEntry(raw: unknown): string {
    +  const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : "";
    +  return base
    +    .trim()
    +    .replace(/^(telegram|tg):/i, "")
    +    .trim();
    +}
    +
    +function isNumericTelegramUserId(raw: string): boolean {
    +  return /^\d+$/.test(raw);
    +}
    +
     function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity {
       const s = message.toLowerCase();
       if (
    @@ -353,10 +365,39 @@ export async function collectChannelSecurityFindings(params: {
     
           const storeAllowFrom = await readChannelAllowFromStore("telegram").catch(() => []);
           const storeHasWildcard = storeAllowFrom.some((v) => String(v).trim() === "*");
    +      const invalidTelegramAllowFromEntries = new Set<string>();
    +      for (const entry of storeAllowFrom) {
    +        const normalized = normalizeTelegramAllowFromEntry(entry);
    +        if (!normalized || normalized === "*") {
    +          continue;
    +        }
    +        if (!isNumericTelegramUserId(normalized)) {
    +          invalidTelegramAllowFromEntries.add(normalized);
    +        }
    +      }
           const groupAllowFrom = Array.isArray(telegramCfg.groupAllowFrom)
             ? telegramCfg.groupAllowFrom
             : [];
           const groupAllowFromHasWildcard = groupAllowFrom.some((v) => String(v).trim() === "*");
    +      for (const entry of groupAllowFrom) {
    +        const normalized = normalizeTelegramAllowFromEntry(entry);
    +        if (!normalized || normalized === "*") {
    +          continue;
    +        }
    +        if (!isNumericTelegramUserId(normalized)) {
    +          invalidTelegramAllowFromEntries.add(normalized);
    +        }
    +      }
    +      const dmAllowFrom = Array.isArray(telegramCfg.allowFrom) ? telegramCfg.allowFrom : [];
    +      for (const entry of dmAllowFrom) {
    +        const normalized = normalizeTelegramAllowFromEntry(entry);
    +        if (!normalized || normalized === "*") {
    +          continue;
    +        }
    +        if (!isNumericTelegramUserId(normalized)) {
    +          invalidTelegramAllowFromEntries.add(normalized);
    +        }
    +      }
           const anyGroupOverride = Boolean(
             groups &&
             Object.values(groups).some((value) => {
    @@ -366,6 +407,15 @@ export async function collectChannelSecurityFindings(params: {
               const group = value as Record<string, unknown>;
               const allowFrom = Array.isArray(group.allowFrom) ? group.allowFrom : [];
               if (allowFrom.length > 0) {
    +            for (const entry of allowFrom) {
    +              const normalized = normalizeTelegramAllowFromEntry(entry);
    +              if (!normalized || normalized === "*") {
    +                continue;
    +              }
    +              if (!isNumericTelegramUserId(normalized)) {
    +                invalidTelegramAllowFromEntries.add(normalized);
    +              }
    +            }
                 return true;
               }
               const topics = group.topics;
    @@ -378,6 +428,15 @@ export async function collectChannelSecurityFindings(params: {
                 }
                 const topic = topicValue as Record<string, unknown>;
                 const topicAllow = Array.isArray(topic.allowFrom) ? topic.allowFrom : [];
    +            for (const entry of topicAllow) {
    +              const normalized = normalizeTelegramAllowFromEntry(entry);
    +              if (!normalized || normalized === "*") {
    +                continue;
    +              }
    +              if (!isNumericTelegramUserId(normalized)) {
    +                invalidTelegramAllowFromEntries.add(normalized);
    +              }
    +            }
                 return topicAllow.length > 0;
               });
             }),
    @@ -386,6 +445,24 @@ export async function collectChannelSecurityFindings(params: {
           const hasAnySenderAllowlist =
             storeAllowFrom.length > 0 || groupAllowFrom.length > 0 || anyGroupOverride;
     
    +      if (invalidTelegramAllowFromEntries.size > 0) {
    +        const examples = Array.from(invalidTelegramAllowFromEntries).slice(0, 5);
    +        const more =
    +          invalidTelegramAllowFromEntries.size > examples.length
    +            ? ` (+${invalidTelegramAllowFromEntries.size - examples.length} more)`
    +            : "";
    +        findings.push({
    +          checkId: "channels.telegram.allowFrom.invalid_entries",
    +          severity: "warn",
    +          title: "Telegram allowlist contains non-numeric entries",
    +          detail:
    +            "Telegram sender authorization requires numeric Telegram user IDs. " +
    +            `Found non-numeric allowFrom entries: ${examples.join(", ")}${more}.`,
    +          remediation:
    +            "Replace @username entries with numeric Telegram user IDs (use onboarding to resolve), then re-run the audit.",
    +        });
    +      }
    +
           if (storeHasWildcard || groupAllowFromHasWildcard) {
             findings.push({
               checkId: "channels.telegram.groups.allowFrom.wildcard",
    @@ -394,7 +471,7 @@ export async function collectChannelSecurityFindings(params: {
               detail:
                 'Telegram group sender allowlist contains "*", which allows any group member to run /… commands and control directives.',
               remediation:
    -            'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit user ids/usernames.',
    +            'Remove "*" from channels.telegram.groupAllowFrom and pairing store; prefer explicit numeric Telegram user IDs.',
             });
             continue;
           }
    
  • src/security/audit.test.ts+44 0 modified
    @@ -1099,6 +1099,50 @@ describe("security audit", () => {
         }
       });
     
    +  it("warns when Telegram allowFrom entries are non-numeric (legacy @username configs)", async () => {
    +    const prevStateDir = process.env.OPENCLAW_STATE_DIR;
    +    const tmp = await fs.mkdtemp(
    +      path.join(os.tmpdir(), "openclaw-security-audit-telegram-invalid-allowfrom-"),
    +    );
    +    process.env.OPENCLAW_STATE_DIR = tmp;
    +    await fs.mkdir(path.join(tmp, "credentials"), { recursive: true, mode: 0o700 });
    +    try {
    +      const cfg: OpenClawConfig = {
    +        channels: {
    +          telegram: {
    +            enabled: true,
    +            botToken: "t",
    +            groupPolicy: "allowlist",
    +            groupAllowFrom: ["@TrustedOperator"],
    +            groups: { "-100123": {} },
    +          },
    +        },
    +      };
    +
    +      const res = await runSecurityAudit({
    +        config: cfg,
    +        includeFilesystem: false,
    +        includeChannelSecurity: true,
    +        plugins: [telegramPlugin],
    +      });
    +
    +      expect(res.findings).toEqual(
    +        expect.arrayContaining([
    +          expect.objectContaining({
    +            checkId: "channels.telegram.allowFrom.invalid_entries",
    +            severity: "warn",
    +          }),
    +        ]),
    +      );
    +    } finally {
    +      if (prevStateDir == null) {
    +        delete process.env.OPENCLAW_STATE_DIR;
    +      } else {
    +        process.env.OPENCLAW_STATE_DIR = prevStateDir;
    +      }
    +    }
    +  });
    +
       it("adds a warning when deep probe fails", async () => {
         const cfg: OpenClawConfig = { gateway: { mode: "local" } };
     
    
  • src/telegram/bot-access.ts+34 22 modified
    @@ -2,25 +2,51 @@ import type { AllowlistMatch } from "../channels/allowlist-match.js";
     
     export type NormalizedAllowFrom = {
       entries: string[];
    -  entriesLower: string[];
       hasWildcard: boolean;
       hasEntries: boolean;
    +  invalidEntries: string[];
     };
     
    -export type AllowFromMatch = AllowlistMatch<"wildcard" | "id" | "username">;
    +export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">;
    +
    +const warnedInvalidEntries = new Set<string>();
    +
    +function warnInvalidAllowFromEntries(entries: string[]) {
    +  if (process.env.VITEST || process.env.NODE_ENV === "test") {
    +    return;
    +  }
    +  for (const entry of entries) {
    +    if (warnedInvalidEntries.has(entry)) {
    +      continue;
    +    }
    +    warnedInvalidEntries.add(entry);
    +    console.warn(
    +      [
    +        "[telegram] Invalid allowFrom entry:",
    +        JSON.stringify(entry),
    +        "- allowFrom/groupAllowFrom authorization requires numeric Telegram sender IDs only.",
    +        'If you had "@username" entries, re-run onboarding (it resolves @username to IDs) or replace them manually.',
    +      ].join(" "),
    +    );
    +  }
    +}
     
     export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
       const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
       const hasWildcard = entries.includes("*");
       const normalized = entries
         .filter((value) => value !== "*")
         .map((value) => value.replace(/^(telegram|tg):/i, ""));
    -  const normalizedLower = normalized.map((value) => value.toLowerCase());
    +  const invalidEntries = normalized.filter((value) => !/^\d+$/.test(value));
    +  if (invalidEntries.length > 0) {
    +    warnInvalidAllowFromEntries([...new Set(invalidEntries)]);
    +  }
    +  const ids = normalized.filter((value) => /^\d+$/.test(value));
       return {
    -    entries: normalized,
    -    entriesLower: normalizedLower,
    +    entries: ids,
         hasWildcard,
         hasEntries: entries.length > 0,
    +    invalidEntries,
       };
     };
     
    @@ -48,7 +74,7 @@ export const isSenderAllowed = (params: {
       senderId?: string;
       senderUsername?: string;
     }) => {
    -  const { allow, senderId, senderUsername } = params;
    +  const { allow, senderId } = params;
       if (!allow.hasEntries) {
         return true;
       }
    @@ -58,19 +84,15 @@ export const isSenderAllowed = (params: {
       if (senderId && allow.entries.includes(senderId)) {
         return true;
       }
    -  const username = senderUsername?.toLowerCase();
    -  if (!username) {
    -    return false;
    -  }
    -  return allow.entriesLower.some((entry) => entry === username || entry === `@${username}`);
    +  return false;
     };
     
     export const resolveSenderAllowMatch = (params: {
       allow: NormalizedAllowFrom;
       senderId?: string;
       senderUsername?: string;
     }): AllowFromMatch => {
    -  const { allow, senderId, senderUsername } = params;
    +  const { allow, senderId } = params;
       if (allow.hasWildcard) {
         return { allowed: true, matchKey: "*", matchSource: "wildcard" };
       }
    @@ -80,15 +102,5 @@ export const resolveSenderAllowMatch = (params: {
       if (senderId && allow.entries.includes(senderId)) {
         return { allowed: true, matchKey: senderId, matchSource: "id" };
       }
    -  const username = senderUsername?.toLowerCase();
    -  if (!username) {
    -    return { allowed: false };
    -  }
    -  const entry = allow.entriesLower.find(
    -    (candidate) => candidate === username || candidate === `@${username}`,
    -  );
    -  if (entry) {
    -    return { allowed: true, matchKey: entry, matchSource: "username" };
    -  }
       return { allowed: false };
     };
    
  • src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts+2 2 modified
    @@ -248,7 +248,7 @@ describe("createTelegramBot", () => {
     
         expect(replySpy).toHaveBeenCalledTimes(1);
       });
    -  it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => {
    +  it("blocks group messages when allowFrom is configured with @username entries (numeric IDs required)", async () => {
         onSpy.mockReset();
         const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
         replySpy.mockReset();
    @@ -276,7 +276,7 @@ describe("createTelegramBot", () => {
           getFile: async () => ({ download: async () => new Uint8Array() }),
         });
     
    -    expect(replySpy).toHaveBeenCalledTimes(1);
    +    expect(replySpy).toHaveBeenCalledTimes(0);
       });
       it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => {
         onSpy.mockReset();
    
  • src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts+2 2 modified
    @@ -159,7 +159,7 @@ describe("createTelegramBot", () => {
     
       // groupPolicy tests
     
    -  it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => {
    +  it("blocks @username allowFrom entries when groupPolicy is 'allowlist' (numeric IDs required)", async () => {
         onSpy.mockReset();
         const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
         replySpy.mockReset();
    @@ -187,7 +187,7 @@ describe("createTelegramBot", () => {
           getFile: async () => ({ download: async () => new Uint8Array() }),
         });
     
    -    expect(replySpy).toHaveBeenCalledTimes(1);
    +    expect(replySpy).toHaveBeenCalledTimes(0);
       });
       it("allows direct messages regardless of groupPolicy", async () => {
         onSpy.mockReset();
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

7

News mentions

0

No linked articles in our index yet.