diff --git a/src/custom-stdio.ts b/src/custom-stdio.ts index e1c3bb4c..bb5d1de9 100644 --- a/src/custom-stdio.ts +++ b/src/custom-stdio.ts @@ -33,8 +33,18 @@ export class FilteredStdioServerTransport extends StdioServerTransport { private clientName: string = 'unknown'; private disableNotifications: boolean = false; + // Serialised write queue for notification/progress/custom writes. + // Ensures at most one `once('drain', ...)` listener is registered at a time, + // preventing the MaxListenersExceededWarning on process.stdout (#391). + private stdoutQueue: string[] = []; + private stdoutDraining: boolean = false; + constructor() { super(); + + // Allow enough listeners for concurrent MCP response drain waits during + // heavy tool usage, without triggering false-positive MaxListeners warnings. + process.stdout.setMaxListeners(50); // Store original methods this.originalConsole = { @@ -123,6 +133,39 @@ export class FilteredStdioServerTransport extends StdioServerTransport { return this.messageBuffer.length; } + /** + * Write a serialised line to stdout via the notification write queue. + * At most one `once('drain', ...)` listener is registered at any time, + * preventing MaxListenersExceededWarning during heavy notification load. + */ + private writeNotificationToStdout(data: string): void { + this.stdoutQueue.push(data); + if (!this.stdoutDraining) { + this.flushNotificationQueue(); + } + } + + private flushNotificationQueue(): void { + while (this.stdoutQueue.length > 0) { + const data = this.stdoutQueue[0]; + const flushed = this.originalStdoutWrite.call(process.stdout, data); + if (flushed) { + this.stdoutQueue.shift(); + } else { + // stdout buffer is full — wait for drain before writing more. + // Using once() ensures the listener is removed immediately after firing. + if (!this.stdoutDraining) { + this.stdoutDraining = true; + process.stdout.once('drain', () => { + this.stdoutDraining = false; + this.flushNotificationQueue(); + }); + } + return; + } + } + } + private setupConsoleRedirection() { console.log = (...args: any[]) => { if (this.isInitialized) { @@ -258,8 +301,9 @@ export class FilteredStdioServerTransport extends StdioServerTransport { } }; - // Send as valid JSON-RPC notification - this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n'); + // Route through the serialised write queue to avoid accumulating + // drain listeners on process.stdout during heavy notification load. + this.writeNotificationToStdout(JSON.stringify(notification) + '\n'); } catch (error) { // Fallback to a simple JSON-RPC error notification if JSON serialization fails const fallbackNotification = { @@ -271,7 +315,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { data: `Log serialization failed: ${args.join(' ')}` } }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n'); } } @@ -307,7 +351,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { } }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(notification) + '\n'); } catch (error) { // Fallback to basic JSON-RPC notification const fallbackNotification = { @@ -319,7 +363,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { data: `sendLog failed: ${message}` } }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n'); } } @@ -343,7 +387,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { } }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(notification) + '\n'); } catch (error) { // Fallback to basic JSON-RPC notification for progress const fallbackNotification = { @@ -355,7 +399,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { data: `Progress ${token}: ${value}${total ? `/${total}` : ''}` } }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n'); } } @@ -375,7 +419,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { params: params }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(notification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(notification) + '\n'); } catch (error) { // Fallback to basic JSON-RPC notification for custom notifications const fallbackNotification = { @@ -387,7 +431,7 @@ export class FilteredStdioServerTransport extends StdioServerTransport { data: `Custom notification failed: ${method}: ${JSON.stringify(params)}` } }; - this.originalStdoutWrite.call(process.stdout, JSON.stringify(fallbackNotification) + '\n'); + this.writeNotificationToStdout(JSON.stringify(fallbackNotification) + '\n'); } } diff --git a/src/tools/filesystem.ts b/src/tools/filesystem.ts index 78c2b2de..2214f530 100644 --- a/src/tools/filesystem.ts +++ b/src/tools/filesystem.ts @@ -202,6 +202,36 @@ export async function validatePath(requestedPath: string): Promise { }); throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`); } + + // SECURITY FIX: When the full path doesn't exist (e.g., writing a new file), + // resolve the parent directory to detect symlinks in the path chain. + // Without this, an attacker could create a symlink inside an allowed directory + // pointing to a restricted location, then write to a non-existent file through + // that symlink — bypassing the directory restriction check. + try { + const parentDir = path.dirname(absoluteOriginal); + const resolvedParent = await fs.realpath(parentDir, { encoding: 'utf8' }); + const basename = path.basename(absoluteOriginal); + resolvedRealPath = path.join(resolvedParent, basename); + } catch { + // Parent also doesn't exist — walk up the tree to find + // the deepest existing ancestor and resolve it + let current = absoluteOriginal; + let remaining: string[] = []; + while (true) { + const parent = path.dirname(current); + if (parent === current) break; // reached filesystem root + remaining.unshift(path.basename(current)); + current = parent; + try { + const resolvedAncestor = await fs.realpath(current, { encoding: 'utf8' }); + resolvedRealPath = path.join(resolvedAncestor, ...remaining); + break; + } catch { + // keep walking up + } + } + } } const pathForNextCheck = resolvedRealPath ?? absoluteOriginal;