Skip to content

[Security / Storage] Insecure plaintext storage and broken Windows file permissions for Telegram bot token in mavis local-runtime #141

Description

@fotedev

Security Disclosure: Plaintext Storage of Telegram Bot Tokens in Mavis Local-Runtime

Researcher: <your name / handle>
Contact: <your email / Signal / Keybase>
Date:
Vendor: MiniMax (Mavis desktop product)
Affected component: @mavis/local-runtime (bundled in MiniMax Code desktop app, shipped as app.asar)
Affected versions observed: @mavis/local-runtime@0.2.1 (bundled with MiniMax Code, build date 2026-09-09)

⚠ Before sending: replace every <placeholder> block. Do not include your real bot token or your Windows username anywhere in this report.


Summary

The Mavis desktop runtime (@mavis/local-runtime) stores Telegram bot tokens, Feishu app secrets, WeChat iLink credentials, and OAuth bearer tokens as plaintext files inside the user's data directory. The runtime attempts to lock them down with fs.chmod(0o600) after writing, but that call is silently ignored on Windows NTFS, so any other interactive user on the same machine can read the bot token. The legacy credential files imported by the one-time legacy-im-migration are also never deleted after migration, leaving an additional plaintext copy behind.

This is not a remote-execution flaw. It is a local information disclosure with material blast radius: any local account (including low-privilege ones) can read an arbitrary user's bound Telegram bot token and impersonate that bot to every chat it has been added to.

Vulnerability classification

  • CWE-256 — Plaintext Storage of a Password
  • CWE-732 — Incorrect Permission Assignment for Critical Resource
  • CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor

Estimated CVSS v3.1: 5.5 (Medium)AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N. Local, low-privilege, no user interaction; high confidentiality impact on the bound bot token.

Affected files and code paths

All paths below are inside the bundled Electron archive at:

<MINIMAX_INSTALL_DIR>/resources/app.asar
└── node_modules/@mavis/local-runtime/
    ├── src/channels/telegram.ts             # active store
    ├── src/channels/legacy-im-migration.ts  # one-time forward migration
    └── src/channels/adapters/telegram/      # consumer
└── node_modules/@mavis/oauth-core/
    └── dist/credential-store/file-store.js  # sibling OAuth store

Active store — writes the bot token in plaintext

LocalTelegramChannelStore.save() in src/channels/telegram.ts:

private async save(): Promise<void> {
  const payload: LocalTelegramChannelFile = {
    schemaVersion: 1,
    bindings: Object.fromEntries(
      [...this.bindings.entries()].sort(([left], [right]) => left.localeCompare(right)),
    ),
  };
  await mkdir(dirname(this.filePath), { recursive: true });
  await writeFile(this.filePath, yaml.dump(payload, { lineWidth: 120, noRefs: true }), {
    encoding: 'utf8',
    mode: 0o600,
  });
  // Explicit chmod — on some filesystems (e.g. FAT32 mounts inside CI) the
  // `mode` option is silently ignored.
  await chmod(this.filePath, 0o600);
}

private get filePath(): string {
  return join(this.dataDir(), 'telegram-channel.yaml');
}

The mode: 0o600 and the follow-up chmod(0o600) are no-ops on Windows NTFS. NTFS ignores POSIX mode bits; fs.chmod on Windows can only toggle the read-only attribute. The file inherits the parent directory's DACL, which on most installations grants Read to the local Users group (every interactive account on the host).

Resulting on-disk files (verified on a test install):

$MINIMAX_DATA_DIR/telegram-channel.yaml             # active store, botToken plaintext
$MINIMAX_DATA_DIR/credentials/mavis/telegram.json   # legacy copy, botToken plaintext
$MINIMAX_DATA_DIR/auth.json                         # OAuth bearer + refresh, FileStore path

Legacy copy is never removed after migration

src/channels/legacy-im-migration.ts reads the legacy JSON and migrates into the new YAML store:

async function migrateTelegram(options, agent, result): Promise<void> {
  const cred = await readLegacyCredential(options.dataDir, agent, 'telegram');
  if (!cred) return;
  const botToken = readFirstString(cred, ['botToken', 'bot_token', 'token']);
  if (!botToken) { result.skipped += 1; return; }
  if (await options.telegramStore.get(agent)) {
    result.skipped += 1;
  } else {
    await options.telegramStore.bind({ agentName: agent, botToken, /* ... */ suppressFamilyMutation: true });
    result.migrated += 1;
  }
  await ensureRoute(options, 'telegram', agent, telegramClientId(agent), result);
}

There is no unlink of the legacy file. It remains on disk with its original permissions.

Sibling OAuth store has the same flaw, explicitly

@mavis/oauth-core/dist/credential-store/file-store.js:

async assertPrivatePermissions() {
    if (process.platform === 'win32')
        return;                          // ← silent skip on Windows
    try {
        const [directory, file] = await Promise.all([stat(this.authHome), stat(this.path)]);
        if ((directory.mode & 0o077) !== 0 || (file.mode & 0o077) !== 0) {
            throw new CredentialStorePermissionError();
        }
    } catch (error) { ... }
}

The win32 early return makes the permission guarantee a fiction on the most common desktop OS.

Impact

  1. Token theft. Any local user (including a low-privilege service account) on the host can cat $MINIMAX_DATA_DIR/telegram-channel.yaml and obtain the bot token. The same is true for the OAuth bearer/refresh token in auth.json.
  2. Bot impersonation. A stolen Telegram token gives full access to the bot's conversations, files, and inline keyboards. If the bot has been added to group chats, the attacker can read and post in them until the token is revoked.
  3. Lateral exposure. If the same host is used by multiple people (shared workstation, RDP host, kiosk mode), every user with a local account is implicitly in scope.
  4. Persistence. The legacy credentials/<agent>/<platform>.json file is never deleted after migration, doubling the on-disk footprint and remaining readable by other local users.

Steps to reproduce (anonymised)

On any host with Mavis / MiniMax Code installed and a Telegram bot bound via the channel UI:

# 1. Locate the user data dir (default on Windows: %USERPROFILE%\\.minimax)
ls "$USERPROFILE/.minimax/"

# 2. Confirm a world-readable copy of the bot token exists
ls -l "$USERPROFILE/.minimax/telegram-channel.yaml"
# expected (MSYS): -rw-r--r--   # mode bits 0644, inherited Users-group DACL

# 3. Confirm the legacy copy is still present after migration
ls -l "$USERPROFILE/.minimax/credentials/<agent>/telegram.json"
# expected (MSYS): -rw-r--r--

# 4. Grep for a Telegram bot token format (digit:base64)
grep -E '"botToken"\\s*:\\s*"[0-9]+:[A-Za-z0-9_-]+"' \\
     "$USERPROFILE/.minimax/telegram-channel.yaml" \\
     "$USERPROFILE/.minimax/credentials/<agent>/telegram.json"

No secret values are reproduced in this report.

Recommended remediation

A complete fix touches the Mavis monorepo (@mavis/local-runtime, @mavis/oauth-core). Suggested order:

  1. Move secrets out of plaintext files. Default to an OS keyring (Windows Credential Manager / macOS Keychain / libsecret) via a thin cross-platform wrapper (@napi-rs/keyring is a known-good choice). Make the plaintext file path a fallback that emits a one-shot startup warning.
  2. Persist a reference, not the secret. LocalTelegramBindingRecord carries a botTokenRef instead of botToken; the per-call sender factory resolves the reference through the keyring.
  3. Fix FileStore.assertPrivatePermissions() on Windows. Drop the process.platform === 'win32' early return. Apply an explicit NTFS DACL via icacls or a Node native addon (e.g. @napi-rs/win32-acl), and fail startup when the resulting ACL still grants read access to non-owners.
  4. Shred the legacy file on successful migration. After legacy-im-migration.ts upserts into the new store, overwrite the source JSON with zeros then fs.unlink it. Add a legacy-shred-at marker so re-runs are no-ops.
  5. Run the migration once on existing installs. A cleanupTokenMigrationV1 flag, set when the user next opens the desktop app, decrypts the on-disk YAML in memory, moves each token into the keyring, and rewrites the YAML with botTokenRef only.
  6. Audit other channels. The same pattern applies to LocalFeishuChannelStore and LocalWeChatChannelStore, and to @mavis/oauth-core's OAuth credential file. Verify each one before declaring the fix complete.

A sketch diff (illustrative, not drop-in) for the active store:

--- a/src/channels/telegram.ts
+++ b/src/channels/telegram.ts
@@ class LocalTelegramChannelStore
-      botToken: input.botToken.trim(),
+      // Persist an opaque reference; the real token lives in the OS keyring.
+      botTokenRef: await this.credentialRef.put({
+        agentName,
+        service: 'telegram',
+        account: clientId,
+        secret: input.botToken.trim(),
+      }),

And for the legacy shred step:

--- a/src/channels/legacy-im-migration.ts
+++ b/src/channels/legacy-im-migration.ts
@@ async function migrateTelegram(...)
     await options.telegramStore.bind({ ... });
+    await shred(join(options.dataDir, 'credentials', agent, 'telegram.json'));
     result.migrated += 1;

Suggested coordinated disclosure timeline

  • T+0 (now): report sent to security@MiniMax.io.
  • T+7: ack expected. If none received, follow up via the support address listed on the Mavis / MiniMax org page.
  • T+30: suggested public disclosure date if a fix is in progress, or sooner if you prefer.
  • T+90: hard deadline for public disclosure if no fix has shipped.

I am happy to coordinate timing, validate candidate fixes against a throwaway install, and to keep the technical details confidential until you confirm a release window.

Contact

  • Email: <your email>
  • Signal / Keybase / etc.: <handle>
  • Disclosure language: English

This report was prepared on a system under the researcher's full control. No real bot token values are included; the on-disk file paths shown are intentionally parameterised and the researcher's Windows username is redacted.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions