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
- 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.
- 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.
- 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.
- 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:
- 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.
- Persist a reference, not the secret.
LocalTelegramBindingRecord carries a botTokenRef instead of botToken; the per-call sender factory resolves the reference through the keyring.
- 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.
- 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.
- 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.
- 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.
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 asapp.asar)Affected versions observed:
@mavis/local-runtime@0.2.1(bundled with MiniMax Code, build date 2026-09-09)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 withfs.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-timelegacy-im-migrationare 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
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:
Active store — writes the bot token in plaintext
LocalTelegramChannelStore.save()insrc/channels/telegram.ts:The
mode: 0o600and the follow-upchmod(0o600)are no-ops on Windows NTFS. NTFS ignores POSIX mode bits;fs.chmodon Windows can only toggle the read-only attribute. The file inherits the parent directory's DACL, which on most installations grantsReadto the localUsersgroup (every interactive account on the host).Resulting on-disk files (verified on a test install):
Legacy copy is never removed after migration
src/channels/legacy-im-migration.tsreads the legacy JSON and migrates into the new YAML store:There is no
unlinkof 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:The
win32early return makes the permission guarantee a fiction on the most common desktop OS.Impact
cat $MINIMAX_DATA_DIR/telegram-channel.yamland obtain the bot token. The same is true for the OAuth bearer/refresh token inauth.json.credentials/<agent>/<platform>.jsonfile 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:
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:@napi-rs/keyringis a known-good choice). Make the plaintext file path a fallback that emits a one-shot startup warning.LocalTelegramBindingRecordcarries abotTokenRefinstead ofbotToken; the per-call sender factory resolves the reference through the keyring.FileStore.assertPrivatePermissions()on Windows. Drop theprocess.platform === 'win32'early return. Apply an explicit NTFS DACL viaicaclsor a Node native addon (e.g.@napi-rs/win32-acl), and fail startup when the resulting ACL still grants read access to non-owners.legacy-im-migration.tsupserts into the new store, overwrite the source JSON with zeros thenfs.unlinkit. Add alegacy-shred-atmarker so re-runs are no-ops.cleanupTokenMigrationV1flag, 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 withbotTokenRefonly.LocalFeishuChannelStoreandLocalWeChatChannelStore, 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:
And for the legacy shred step:
Suggested coordinated disclosure timeline
security@MiniMax.io.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
<your email><handle>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.