Root cause
Injection & unsafe execution introduced
Add comprehensive OAuth 1.0a authentication and testing suite
How AI contributed: Direct introductionPublished Nov 6, 2025 in
brentmid/evernote-mcp-server
Root cause
Add comprehensive OAuth 1.0a authentication and testing suite
Fix
security: Fix command injection vulnerability in openBrowser function
Case facts
@@ -9,6 +9,11 @@ const express = require('express'); const https = require('https'); const fs = require('fs'); +/**+ * Import authentication module for Evernote OAuth flow+ */+const auth = require('./auth');+ /** * Create an Express application instance */@@ -25,6 +30,11 @@ const port = process.env.PORT || 3443; */ app.use(express.json()); +/**+ * Store OAuth state for token exchange+ */+let oauthState = {};+ /** * Health check endpoint - GET request to root path * Returns a simple JSON response to confirm the server is running@@ -33,16 +43,69 @@ app.get('/', (req, res) => { res.json({ message: 'Evernote MCP Server is running.' }); }); +/**+ * OAuth callback endpoint - handles Evernote authorization response+ * Completes the OAuth token exchange process+ */+app.get('/oauth/callback', async (req, res) => {+ try {+ const { oauth_token, oauth_verifier } = req.query;+ + if (!oauth_token || !oauth_verifier) {+ return res.status(400).json({ error: 'Missing OAuth parameters' });+ }+ + // Retrieve stored request token secret+ const requestTokenSecret = oauthState[oauth_token];+ if (!requestTokenSecret) {+ return res.status(400).json({ error: 'Invalid OAuth state' });+ }+ + // Complete token exchange+ const tokenData = await auth.handleCallback(oauth_token, oauth_verifier, requestTokenSecret);+ + // Clean up OAuth state+ delete oauthState[oauth_token];+ + res.json({+ message: 'OAuth authentication successful!',+ success: true+ });+ + console.log('🎉 OAuth authentication completed successfully');+ + } catch (error) {+ console.error('❌ OAuth callback error:', error.message);+ res.status(500).json({ error: 'OAuth authentication failed' });+ }+});+ /** * MCP (Model Context Protocol) endpoint - POST request to /mcp * Logs the incoming request body and returns a confirmation response */-app.post('/mcp', (req, res) => {- console.log('MCP request received:', req.body);- res.json({ - message: 'MCP request received',- received: true- });+app.post('/mcp', async (req, res) => {+ try {+ // Check if we have valid authentication+ const tokenData = await auth.getTokenFromKeychain();+ if (!tokenData) {+ return res.status(401).json({ + error: 'Not authenticated',+ message: 'Please complete OAuth authentication first'+ });+ }+ + console.log('MCP request received:', req.body);+ res.json({ + message: 'MCP request received',+ received: true,@@ -0,0 +1,291 @@+/**+ * Evernote OAuth 1.0a Authentication Module+ * Handles the complete OAuth flow including token storage in macOS Keychain+ */++const crypto = require('crypto');+const https = require('https');+const url = require('url');+const querystring = require('querystring');+const keytar = require('keytar');+const { exec } = require('child_process');++/**+ * Configuration for Evernote OAuth+ * Note: You'll need to register your app at https://dev.evernote.com/+ */+const EVERNOTE_CONFIG = {+ // Sandbox URLs for development+ requestTokenUrl: 'https://sandbox.evernote.com/oauth',+ authorizeUrl: 'https://sandbox.evernote.com/OAuth.action',+ accessTokenUrl: 'https://sandbox.evernote.com/oauth',+ + // Production URLs (uncomment when ready for production)+ // requestTokenUrl: 'https://www.evernote.com/oauth',+ // authorizeUrl: 'https://www.evernote.com/OAuth.action', + // accessTokenUrl: 'https://www.evernote.com/oauth',+ + callbackUrl: 'https://localhost:3443/oauth/callback',+ serviceName: 'evernote-mcp-server',+ + // These should be set via environment variables+ consumerKey: process.env.EVERNOTE_CONSUMER_KEY || 'your-consumer-key',+ consumerSecret: process.env.EVERNOTE_CONSUMER_SECRET || 'your-consumer-secret'+};++/**+ * Generate OAuth 1.0a signature using HMAC-SHA1+ * @param {string} method - HTTP method (GET/POST)+ * @param {string} baseUrl - Base URL without query parameters+ * @param {Object} params - OAuth parameters+ * @param {string} tokenSecret - Token secret (empty string for request token)+ * @returns {string} Base64 encoded signature+ */+function generateSignature(method, baseUrl, params, tokenSecret = '') {+ // Create signature base string+ const sortedParams = Object.keys(params)+ .sort()+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)+ .join('&');+ + const signatureBaseString = [+ method.toUpperCase(),+ encodeURIComponent(baseUrl),+ encodeURIComponent(sortedParams)+ ].join('&');+ + // Create signing key+ const signingKey = `${encodeURIComponent(EVERNOTE_CONFIG.consumerSecret)}&${encodeURIComponent(tokenSecret)}`;+ + // Generate signature+ const signature = crypto+ .createHmac('sha1', signingKey)+ .update(signatureBaseString)+ .digest('base64');+ + return signature;+}++/**+ * Generate OAuth 1.0a parameters+ * @param {string} token - OAuth token (optional)+ * @param {string} verifier - OAuth verifier (optional)+ * @returns {Object} OAuth parameters+ */+function generateOAuthParams(token = null, verifier = null) {+ const params = {+ oauth_consumer_key: EVERNOTE_CONFIG.consumerKey,+ oauth_nonce: crypto.randomBytes(16).toString('hex'),+ oauth_signature_method: 'HMAC-SHA1',+ oauth_timestamp: Math.floor(Date.now() / 1000).toString(),+ oauth_version: '1.0',+ oauth_callback: EVERNOTE_CONFIG.callbackUrl+ };+ + if (token) {+ params.oauth_token = token;+ delete params.oauth_callback; // Not needed for access token request+ }+ @@ -0,0 +1,45 @@+/**+ * Jest configuration for Evernote MCP Server+ */++module.exports = {+ // Test environment+ testEnvironment: 'node',+ + // Test file patterns+ testMatch: [+ '**/tests/**/*.test.js'+ ],+ + // Coverage configuration+ collectCoverageFrom: [+ '*.js',+ '!jest.config.js',+ '!coverage/**'+ ],+ + // Coverage thresholds+ coverageThreshold: {+ global: {+ branches: 70,+ functions: 80,+ lines: 80,+ statements: 80+ }+ },+ + // Setup files+ setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],+ + // Module directories+ moduleDirectories: ['node_modules', '<rootDir>'],+ + // Clear mocks between tests+ clearMocks: true,+ + // Verbose output+ verbose: true,+ + // Test timeout+ testTimeout: 10000+};@@ -7,7 +7,7 @@ const crypto = require('crypto'); const https = require('https'); const url = require('url'); const querystring = require('querystring');-const { exec } = require('child_process');+const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); @@ -338,21 +338,43 @@ async function clearStoredTokens() { /** * Launch system browser to open URL+ * SECURITY: Uses spawn() instead of exec() to prevent command injection * @param {string} url - URL to open */ function openBrowser(url) {- const command = process.platform === 'darwin' ? 'open' : - process.platform === 'win32' ? 'start' : 'xdg-open';- - exec(`${command} "${url}"`, (error) => {- if (error) {- console.error('❌ Failed to open browser:', error.message);- console.error('📋 Please manually open this URL in your browser:');- console.error(url);- } else {- console.error('🌐 Browser opened for Evernote authorization');- }+ let command, args;++ if (process.platform === 'darwin') {+ // macOS: open <url>+ command = 'open';+ args = [url];+ } else if (process.platform === 'win32') {+ // Windows: cmd /c start "" <url>+ // The empty string "" is the window title parameter for start command+ command = 'cmd';+ args = ['/c', 'start', '', url];+ } else {+ // Linux: xdg-open <url>+ command = 'xdg-open';+ args = [url];+ }++ // Use spawn() with array arguments - does NOT invoke shell, preventing injection+ const child = spawn(command, args, {+ stdio: 'ignore', // Don't pipe stdio+ detached: true // Allow parent to exit independently });++ child.on('error', (error) => {+ console.error('❌ Failed to open browser:', error.message);+ console.error('📋 Please manually open this URL in your browser:');+ console.error(url);+ });++ // Allow the parent process to exit independently of the child+ child.unref();++ console.error('🌐 Browser opened for Evernote authorization'); } /**@@ -7,11 +7,17 @@ const querystring = require('querystring'); // No longer need to mock keytar as we use environment variables +// Mock child_process to use secure spawn instead of exec+const mockSpawn = jest.fn(() => ({+ on: jest.fn(),+ unref: jest.fn()+}));+ jest.mock('child_process', () => ({- exec: jest.fn((cmd, callback) => callback(null))+ spawn: mockSpawn })); -const { exec } = require('child_process');+const { spawn } = require('child_process'); // Set test environment process.env.EVERNOTE_CONSUMER_KEY = 'test-integration-key';@@ -110,17 +116,23 @@ describe('OAuth Flow Integration Tests', () => { }); describe('Browser Integration', () => {- test('should attempt to open browser with correct authorization URL', () => {+ test('should attempt to open browser with correct authorization URL using spawn', () => { const requestToken = 'test_request_token'; const expectedUrl = `${auth.EVERNOTE_CONFIG.authorizeUrl}?oauth_token=${requestToken}`;- - // Simulate browser opening (mocked)- const openCommand = process.platform === 'darwin' ? 'open' : - process.platform === 'win32' ? 'start' : 'xdg-open';- - exec(`${openCommand} "${expectedUrl}"`, () => {});- - expect(exec).toHaveBeenCalledWith(`${openCommand} "${expectedUrl}"`, expect.any(Function));++ // Clear previous calls+ mockSpawn.mockClear();++ // Simulate browser opening with spawn (secure, no shell execution)+ const openCommand = process.platform === 'darwin' ? 'open' :+ process.platform === 'win32' ? 'cmd' : 'xdg-open';++ const args = process.platform === 'win32' ? ['/c', 'start', '', expectedUrl] : [expectedUrl];++ spawn(openCommand, args, { stdio: 'ignore', detached: true });++ expect(spawn).toHaveBeenCalled();+ expect(spawn).toHaveBeenCalledWith(openCommand, args, { stdio: 'ignore', detached: true }); }); }); @@ -230,7 +242,6 @@ describe('OAuth Flow Integration Tests', () => { const result = await auth.getTokenFromEnv(); expect(result).toBeNull(); });- }) }); describe('OAuth Parameter Validation', () => {@@ -19,14 +19,20 @@ jest.mock('fs', () => ({ })); // Mock child_process to avoid opening browser during tests+// Now using spawn() instead of exec() for security+const mockSpawn = jest.fn(() => ({+ on: jest.fn(),+ unref: jest.fn()+}));+ jest.mock('child_process', () => ({- exec: jest.fn((cmd, callback) => callback(null))+ spawn: mockSpawn })); // Import after mocking const fs = require('fs'); const keytar = require('keytar');-const { exec } = require('child_process');+const { spawn } = require('child_process'); // We need to test the auth module, but it uses environment variables // Set test environment variables@@ -220,14 +226,19 @@ describe('Auth Module', () => { }); describe('Browser Integration', () => {- test('should attempt to open browser for authorization', () => {- // Test that exec is called when opening browser- const { exec } = require('child_process');- + test('should attempt to open browser for authorization using spawn', () => {+ // Test that spawn is called when opening browser (secure, no shell execution)+ const { spawn } = require('child_process');++ // Clear previous calls+ mockSpawn.mockClear();+ // Simulate opening browser (this is mocked)- exec('open "https://example.com"', () => {});- - expect(exec).toHaveBeenCalled();+ // The actual openBrowser function will call spawn with proper arguments+ spawn('open', ['https://example.com'], { stdio: 'ignore', detached: true });++ expect(spawn).toHaveBeenCalled();+ expect(spawn).toHaveBeenCalledWith('open', ['https://example.com'], { stdio: 'ignore', detached: true }); }); }); AI-assisted change ed5730ec21afbeb27675dccaba44e2554686177b8c3a8b9edf76683b62ef525b · Fix 25306b9db8b4ff1f6d37ee16dda620020ae9b233bad7d1d8de49fd88c53267fe