MiniMax-AI / MiniMax-AI/minimax-code

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

Open
#141 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug desktop needs-triage
Dominant language
TypeScript
Stars
1.3k
Forks
141
Avg merge
2h 45m
Merged PRs (30d)
46

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/channels/telegram.ts and src/channels/legacy-im-migration.ts, then inspect @mavis/oauth-core/dist/credential-store/file-store.js and reproduce the Windows permission behavior using the listed paths. Trace how Telegram, legacy, and OAuth credentials are written and migrated. Done means sensitive values are no longer exposed to other local users, Windows permissions are enforced, and successful migration removes the legacy plaintext copy.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, node.js, typescript
Domain
desktop-dev, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.