CVE-2026-10277
Description
A vulnerability was found in j3k0 mcp-google-workspace up to 831790e7d5c2663325733d9f5579cc339a267c4c. This issue affects the function saveToDisk of the file src/tools/gmail.ts of the component MCP Gmail Tool. Performing a manipulation results in improper access controls. It is possible to initiate the attack remotely. The exploit has been made public and could be used. This product is using a rolling release to provide continious delivery. Therefore, no version details for affected nor updated releases are available. The patch is named 89c091ecf8b9f9c7291d1af0b1966e271f86551c. It is suggested to install a patch to address this issue.
Patches
189c091ecf8b9fix(gmail): sandbox attachment writes under GMAIL_ATTACHMENTS_DIR (CWE-73) (#22)
5 files changed · +149 −18
README.md+6 −0 modified@@ -207,6 +207,12 @@ On Windows: Edit `%APPDATA%/Claude/claude_desktop_config.json` - Listen on port 4100 for the OAuth2 callback - Store the credentials for future use in a file named `.oauth2.{email}.json` +### Environment Variables + +- `GMAIL_ALLOW_SENDING` — set to `true` to allow `gmail_send` to actually send mail. Defaults to disabled. +- `GMAIL_ALLOW_DRAFTS` — set to `true` to allow draft creation tools. Defaults to disabled. +- `GMAIL_ATTACHMENTS_DIR` — base directory under which `gmail_get_attachment` and `gmail_bulk_save_attachments` may write files. Attachment paths supplied by the caller are treated as relative to this directory; absolute paths, traversal, and symlinks that escape the directory are rejected. Defaults to `~/.mcp-gsuite/attachments`. + ## Available Tools ### Account Management
src/tools/gmail-helpers.ts+49 −0 added@@ -0,0 +1,49 @@ +import { Buffer } from 'buffer'; +import fs from 'fs'; +import os from 'os'; +import * as path from 'path'; + +export function decodeBase64Data(fileData: string): Buffer { + const standardBase64Data = fileData.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - standardBase64Data.length % 4) % 4); + return Buffer.from(standardBase64Data + padding, 'base64'); +} + +function getAttachmentsBaseDir(): string { + const fromEnv = process.env.GMAIL_ATTACHMENTS_DIR; + const baseDir = fromEnv && fromEnv.length > 0 + ? path.resolve(fromEnv) + : path.join(os.homedir(), '.mcp-gsuite', 'attachments'); + fs.mkdirSync(baseDir, { recursive: true }); + return baseDir; +} + +/** + * Resolves a caller-supplied attachment filename against the configured + * attachments base directory (GMAIL_ATTACHMENTS_DIR, defaulting to + * ~/.mcp-gsuite/attachments). Absolute paths, traversal, NUL bytes and + * symlink escapes are rejected. + */ +export function resolveAttachmentPath(filePath: string): string { + if (typeof filePath !== 'string' || filePath.length === 0 || filePath.includes('\0')) { + throw new Error('Invalid save path'); + } + if (path.isAbsolute(filePath)) { + throw new Error( + `Absolute save paths are not allowed; provide a relative path under GMAIL_ATTACHMENTS_DIR (got: ${filePath})` + ); + } + const baseDir = getAttachmentsBaseDir(); + const resolved = path.resolve(baseDir, filePath); + if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) { + throw new Error(`Save path escapes attachments directory: ${filePath}`); + } + const parent = path.dirname(resolved); + fs.mkdirSync(parent, { recursive: true }); + const realBase = fs.realpathSync(baseDir); + const realParent = fs.realpathSync(parent); + if (realParent !== realBase && !realParent.startsWith(realBase + path.sep)) { + throw new Error(`Save path escapes attachments directory via symlink: ${filePath}`); + } + return resolved; +}
src/tools/gmail.ts+5 −17 modified@@ -4,13 +4,9 @@ import { google } from 'googleapis'; import { USER_ID_ARG } from '../types/tool-handler.js'; import { Buffer } from 'buffer'; import fs from 'fs'; -import * as path from 'path'; +import { decodeBase64Data, resolveAttachmentPath } from './gmail-helpers.js'; -export function decodeBase64Data(fileData: string): Buffer { - const standardBase64Data = fileData.replace(/-/g, '+').replace(/_/g, '/'); - const padding = '='.repeat((4 - standardBase64Data.length % 4) % 4); - return Buffer.from(standardBase64Data + padding, 'base64'); -} +export { decodeBase64Data, resolveAttachmentPath } from './gmail-helpers.js'; export class GmailTools { private gmail: ReturnType<typeof google.gmail>; @@ -36,16 +32,8 @@ export class GmailTools { return value.replace(/[\r\n]/g, ''); } - /** - * Validates that a save path does not escape its parent directory via traversal. - */ private validateSavePath(filePath: string): string { - const resolved = path.resolve(filePath); - const dir = path.resolve(path.dirname(filePath)); - if (resolved.includes('..') || dir.includes('..')) { - throw new Error(`Path traversal detected: ${filePath}`); - } - return resolved; + return resolveAttachmentPath(filePath); } private extractEmailText(payload: any): string { @@ -313,7 +301,7 @@ export class GmailTools { }, save_to_disk: { type: 'string', - description: 'The fullpath to save the attachment to disk. If not provided, the attachment is returned as a resource.' + description: 'Relative path (under GMAIL_ATTACHMENTS_DIR, default ~/.mcp-gsuite/attachments) where the attachment is written. Absolute paths and traversal (e.g. "../") are rejected. If not provided, the attachment is returned as a resource.' } }, required: ['message_id', 'attachment_id', 'mime_type', 'filename', USER_ID_ARG] @@ -344,7 +332,7 @@ export class GmailTools { }, save_path: { type: 'string', - description: 'Path where the attachment should be saved' + description: 'Relative path (under GMAIL_ATTACHMENTS_DIR, default ~/.mcp-gsuite/attachments) where the attachment is written. Absolute paths and traversal (e.g. "../") are rejected.' } }, required: ['message_id', 'part_id', 'save_path']
src/tools/__tests__/decodeBase64Data.test.ts+1 −1 modified@@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { Buffer } from 'buffer'; -import { decodeBase64Data } from '../gmail.js'; +import { decodeBase64Data } from '../gmail-helpers.js'; test('decodes standard base64 ASCII payload', () => { const input = Buffer.from('Hello, world!', 'utf-8').toString('base64');
src/tools/__tests__/resolveAttachmentPath.test.ts+88 −0 added@@ -0,0 +1,88 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import * as path from 'path'; +import { resolveAttachmentPath } from '../gmail-helpers.js'; + +let baseDir: string; +let prevEnv: string | undefined; + +beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-gsuite-attach-')); + prevEnv = process.env.GMAIL_ATTACHMENTS_DIR; + process.env.GMAIL_ATTACHMENTS_DIR = baseDir; +}); + +afterEach(() => { + if (prevEnv === undefined) delete process.env.GMAIL_ATTACHMENTS_DIR; + else process.env.GMAIL_ATTACHMENTS_DIR = prevEnv; + fs.rmSync(baseDir, { recursive: true, force: true }); +}); + +test('resolves a simple relative path under the base directory', () => { + const resolved = resolveAttachmentPath('file.pdf'); + const realBase = fs.realpathSync(baseDir); + assert.equal(resolved, path.join(baseDir, 'file.pdf')); + assert.ok(fs.realpathSync(path.dirname(resolved)) === realBase); +}); + +test('resolves a nested relative path and creates parent directories', () => { + const resolved = resolveAttachmentPath(path.join('sub', 'deep', 'file.bin')); + assert.equal(resolved, path.join(baseDir, 'sub', 'deep', 'file.bin')); + assert.ok(fs.statSync(path.dirname(resolved)).isDirectory()); +}); + +test('rejects absolute POSIX paths', () => { + assert.throws(() => resolveAttachmentPath('/etc/passwd'), /Absolute save paths/); +}); + +test('rejects paths that traverse out of the base directory', () => { + assert.throws( + () => resolveAttachmentPath(path.join('..', 'escape.txt')), + /escapes attachments directory/ + ); + assert.throws( + () => resolveAttachmentPath(path.join('sub', '..', '..', 'escape.txt')), + /escapes attachments directory/ + ); +}); + +test('rejects empty path', () => { + assert.throws(() => resolveAttachmentPath(''), /Invalid save path/); +}); + +test('rejects paths containing NUL bytes', () => { + assert.throws(() => resolveAttachmentPath('file\0.txt'), /Invalid save path/); +}); + +test('rejects symlink that escapes the base directory', () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-gsuite-outside-')); + try { + fs.symlinkSync(outside, path.join(baseDir, 'evil')); + assert.throws( + () => resolveAttachmentPath(path.join('evil', 'pwn.txt')), + /symlink/ + ); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test('allows path that resolves exactly to the base directory itself only via subpath', () => { + // Writing to a directory path (no filename) is nonsense; we just verify + // the canonical happy-path: a filename directly under base resolves to base/filename. + const resolved = resolveAttachmentPath('a.txt'); + assert.equal(path.dirname(resolved), baseDir); +}); + +test('honours updated GMAIL_ATTACHMENTS_DIR between calls', () => { + const otherBase = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-gsuite-attach2-')); + try { + process.env.GMAIL_ATTACHMENTS_DIR = otherBase; + const resolved = resolveAttachmentPath('x.txt'); + assert.equal(resolved, path.join(otherBase, 'x.txt')); + } finally { + fs.rmSync(otherBase, { recursive: true, force: true }); + } +});
Vulnerability mechanics
Root cause
"The saveToDisk function does not properly validate the save path, allowing arbitrary file writes."
Attack vector
An attacker can invoke the affected MCP tool, specifically the Gmail attachment saving functionality, to trigger this vulnerability. By providing a crafted path argument to the saveToDisk function, an attacker can cause the decoded attachment content to be written to arbitrary local file paths that are writable by the server process. This attack can be initiated remotely, as the MCP server listens for commands. [ref_id=2]
Affected code
The vulnerability resides in the `saveToDisk` function within the `src/tools/gmail.ts` file. This function, along with the `attachments[].save_path` tool arguments, is responsible for handling Gmail attachment saving. The `fs.writeFileSync` function is called with a path that is validated by a helper function, but this validation is insufficient to prevent arbitrary file writes. [ref_id=2]
What the fix does
The patch, identified by commit hash 89c091ecf8b9f9c7291d1af0b1966e271f86551c, addresses the vulnerability by improving the validation of save paths. The advisory suggests that the `validateSavePath` helper should be modified to enforce a safe base directory and ensure that resolved paths do not escape this allowed directory. Additionally, it is recommended to generate server-controlled filenames where possible and add regression tests for path traversal payloads. [ref_id=2]
Preconditions
- inputAttacker must be able to invoke the affected Gmail MCP attachment tool.
- inputA Gmail account or mocked Gmail API response must provide attachment content.
- configThe target path must be writable by the MCP server process.
Reproduction
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"<gmail_attachment_tool>","arguments":{"userId":"me","messageId":"<TEST_MESSAGE_ID>","attachmentId":"<TEST_ATTACHMENT_ID>","saveToDisk":"C:\\Users\\czx\\Desktop\\cve\\google-workspace-write-test.txt"}}}
Generated on Jun 1, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7News mentions
0No linked articles in our index yet.