diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5ed4f7d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + - run: npm run build + - run: npm test diff --git a/.releaserc.json b/.releaserc.json index 8a27fd0..93fd2bf 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -3,7 +3,15 @@ "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", + "@semantic-release/changelog", "@semantic-release/npm", - "@semantic-release/github" + "@semantic-release/github", + [ + "@semantic-release/git", + { + "assets": ["package.json", "package-lock.json", "CHANGELOG.md"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ] ] } diff --git a/package.json b/package.json index fd6bc41..e77ecde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nullpath-mcp", - "version": "1.2.0", + "version": "1.4.1", "description": "Connect to nullpath's AI agent marketplace via MCP. Discover and pay agents with x402 micropayments.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -44,6 +44,16 @@ "typescript": "^5.0.0", "vitest": "^2.0.0" }, + "files": [ + "dist/index.js", + "dist/index.d.ts", + "dist/index.d.ts.map", + "dist/index.js.map", + "dist/lib/**", + "!dist/__tests__", + "README.md", + "LICENSE" + ], "engines": { "node": ">=18.0.0" } diff --git a/src/__tests__/awal.test.ts b/src/__tests__/awal.test.ts index f099816..fc78c4b 100644 --- a/src/__tests__/awal.test.ts +++ b/src/__tests__/awal.test.ts @@ -22,6 +22,7 @@ import { awalPay, AwalPaymentError, USE_AWAL_ENV, + getAwalVersion, } from '../lib/awal.js'; describe('awal', () => { @@ -33,6 +34,9 @@ describe('awal', () => { afterEach(() => { delete process.env[USE_AWAL_ENV]; + delete process.env.NULLPATH_ALLOW_HTTP; + delete process.env.NULLPATH_MAX_PAYMENT; + delete process.env.NULLPATH_WALLET_KEY; }); describe('isAwalForced', () => { @@ -143,7 +147,7 @@ describe('awal', () => { expect(mockExecFileAsync).toHaveBeenCalled(); const [command, args] = mockExecFileAsync.mock.calls[0]; expect(command).toBe('npx'); - expect(args).toContain('awal@latest'); + expect(args).toContain('awal@2.0.3'); expect(args).toContain('x402'); expect(args).toContain('pay'); expect(args).toContain('https://example.com/api'); @@ -354,7 +358,7 @@ describe('awal', () => { // Verify we called with 'npx' as command and array of args expect(mockExecFileAsync).toHaveBeenCalledWith( 'npx', - expect.arrayContaining(['awal@latest', 'x402', 'pay']), + expect.arrayContaining(['awal@2.0.3', 'x402', 'pay']), expect.any(Object) ); }); @@ -383,4 +387,231 @@ describe('awal', () => { expect(mockExecFileAsync).toHaveBeenCalledTimes(2); }); }); + + describe('getAwalVersion', () => { + it('returns the pinned awal version', () => { + expect(getAwalVersion()).toBe('2.0.3'); + }); + }); + + describe('URL validation', () => { + it('allows HTTPS URLs', async () => { + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true, body: { result: 'ok' } }), + stderr: '', + }); + + const result = await awalPay('https://nullpath.com/api/v1/execute'); + expect(result.success).toBe(true); + }); + + it('rejects HTTP URLs by default', async () => { + await expect(awalPay('http://example.com/api')).rejects.toThrow(AwalPaymentError); + await expect(awalPay('http://example.com/api')).rejects.toThrow('Only HTTPS URLs are allowed'); + }); + + it('rejects file:// URLs', async () => { + await expect(awalPay('file:///etc/passwd')).rejects.toThrow(AwalPaymentError); + }); + + it('rejects javascript: URLs', async () => { + await expect(awalPay('javascript:alert(1)')).rejects.toThrow(AwalPaymentError); + }); + + it('rejects invalid URLs', async () => { + await expect(awalPay('not-a-url')).rejects.toThrow('Invalid URL'); + }); + + it('allows http://localhost when NULLPATH_ALLOW_HTTP=true', async () => { + process.env.NULLPATH_ALLOW_HTTP = 'true'; + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + const result = await awalPay('http://localhost:8787/api/v1/execute'); + expect(result.success).toBe(true); + }); + + it('allows http://127.0.0.1 when NULLPATH_ALLOW_HTTP=true', async () => { + process.env.NULLPATH_ALLOW_HTTP = 'true'; + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + const result = await awalPay('http://127.0.0.1:8787/api/v1/execute'); + expect(result.success).toBe(true); + }); + + it('rejects http://evil.com even with NULLPATH_ALLOW_HTTP=true', async () => { + process.env.NULLPATH_ALLOW_HTTP = 'true'; + await expect(awalPay('http://evil.com/api')).rejects.toThrow('Only HTTPS URLs are allowed'); + }); + }); + + describe('header validation', () => { + it('allows valid headers', async () => { + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + const result = await awalPay('https://example.com', { + headers: { 'Content-Type': 'application/json', 'X-Custom_Header': 'value' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects header names with special characters', async () => { + await expect( + awalPay('https://example.com', { headers: { 'Bad Header!': 'value' } }) + ).rejects.toThrow('Invalid header name'); + }); + + it('rejects header values with newlines', async () => { + await expect( + awalPay('https://example.com', { headers: { 'X-Test': 'value\r\nInjected: header' } }) + ).rejects.toThrow('Header value contains newline'); + }); + }); + + describe('HTTP method validation', () => { + it('allows valid HTTP methods', async () => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']) { + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + const result = await awalPay('https://example.com', { method }); + expect(result.success).toBe(true); + } + }); + + it('rejects invalid HTTP methods', async () => { + await expect( + awalPay('https://example.com', { method: 'HACK' }) + ).rejects.toThrow('Invalid HTTP method'); + }); + + it('is case-insensitive', async () => { + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + const result = await awalPay('https://example.com', { method: 'post' }); + expect(result.success).toBe(true); + }); + }); + + describe('body size limit', () => { + it('allows normal-sized bodies', async () => { + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + const result = await awalPay('https://example.com', { + method: 'POST', + body: JSON.stringify({ text: 'hello world' }), + }); + expect(result.success).toBe(true); + }); + + it('rejects bodies exceeding 1MB', async () => { + const largeBody = 'x'.repeat(1_048_577); // 1MB + 1 byte + await expect( + awalPay('https://example.com', { method: 'POST', body: largeBody }) + ).rejects.toThrow('body exceeds'); + }); + }); + + describe('env sanitization', () => { + it('does not pass NULLPATH_WALLET_KEY to child process', async () => { + process.env.NULLPATH_WALLET_KEY = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + await awalPay('https://example.com'); + + const envPassed = mockExecFileAsync.mock.calls[0][2].env; + expect(envPassed.NULLPATH_WALLET_KEY).toBeUndefined(); + expect(envPassed.NO_COLOR).toBe('1'); + }); + + it('strips common secret env var patterns', async () => { + process.env.AWS_SECRET_ACCESS_KEY = 'aws-secret'; + process.env.GH_TOKEN = 'ghp_fake'; + process.env.DATABASE_PASSWORD = 'dbpass'; + process.env.MY_API_KEY = 'key123'; + process.env.SAFE_VARIABLE = 'safe'; + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + await awalPay('https://example.com'); + + const envPassed = mockExecFileAsync.mock.calls[0][2].env; + expect(envPassed.AWS_SECRET_ACCESS_KEY).toBeUndefined(); + expect(envPassed.GH_TOKEN).toBeUndefined(); + expect(envPassed.DATABASE_PASSWORD).toBeUndefined(); + expect(envPassed.MY_API_KEY).toBeUndefined(); + expect(envPassed.SAFE_VARIABLE).toBe('safe'); + + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.GH_TOKEN; + delete process.env.DATABASE_PASSWORD; + delete process.env.MY_API_KEY; + delete process.env.SAFE_VARIABLE; + }); + }); + + describe('max payment cap', () => { + it('passes --max-amount with default value', async () => { + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + await awalPay('https://example.com'); + + const args = mockExecFileAsync.mock.calls[0][1]; + expect(args).toContain('--max-amount'); + const maxIdx = args.indexOf('--max-amount'); + expect(args[maxIdx + 1]).toBe('10000000'); // $10 default + }); + + it('uses NULLPATH_MAX_PAYMENT env var when set', async () => { + process.env.NULLPATH_MAX_PAYMENT = '5000000'; // $5 + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + await awalPay('https://example.com'); + + const args = mockExecFileAsync.mock.calls[0][1]; + const maxIdx = args.indexOf('--max-amount'); + expect(args[maxIdx + 1]).toBe('5000000'); + }); + + it('falls back to default for invalid NULLPATH_MAX_PAYMENT', async () => { + process.env.NULLPATH_MAX_PAYMENT = 'not-a-number'; + mockExecFileAsync.mockResolvedValueOnce({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + + await awalPay('https://example.com'); + + const args = mockExecFileAsync.mock.calls[0][1]; + const maxIdx = args.indexOf('--max-amount'); + expect(args[maxIdx + 1]).toBe('10000000'); // Falls back to default + }); + }); }); diff --git a/src/index.ts b/src/index.ts index c69c306..dfaca5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -405,7 +405,7 @@ async function main() { const server = new Server( { name: 'nullpath-mcp', - version: '1.2.0', + version: '1.4.1', }, { capabilities: { diff --git a/src/lib/awal.ts b/src/lib/awal.ts index 6789590..7299360 100644 --- a/src/lib/awal.ts +++ b/src/lib/awal.ts @@ -4,6 +4,12 @@ * Provides alternative payment method using awal CLI for x402 payments. * Falls back to direct EIP-3009 signing if awal is not available. * + * Security notes: + * - Uses execFile (not exec) to prevent shell injection + * - Pins awal version to avoid supply chain attacks via @latest + * - Validates and sanitizes all inputs passed to CLI + * - Enforces max payment amount as safety cap + * * @see https://docs.cdp.coinbase.com/agentic-wallet/skills/pay-for-service */ @@ -12,6 +18,41 @@ import { promisify } from 'util'; const execFileAsync = promisify(execFile); +/** + * Pinned awal version — update deliberately, not automatically. + * Avoids supply chain risk from @latest pulling compromised versions. + */ +const AWAL_VERSION = '2.0.3'; +const AWAL_PACKAGE = `awal@${AWAL_VERSION}`; + +/** + * Default max payment amount in USDC atomic units (6 decimals). + * $10.00 = 10_000_000 atomic units. Override with NULLPATH_MAX_PAYMENT env var. + */ +const DEFAULT_MAX_PAYMENT = '10000000'; + +/** + * Max body size in bytes (1MB). Prevents OOM from large payloads hitting maxBuffer. + */ +const MAX_BODY_BYTES = 1_048_576; + +/** + * Patterns that indicate a sensitive environment variable. + * Uses regex for broader matching to catch variants like AWS_SECRET_ACCESS_KEY, GH_TOKEN, etc. + */ +const SENSITIVE_ENV_PATTERNS = [ + /PRIVATE.?KEY/i, + /WALLET.?KEY/i, + /SECRET/i, + /MNEMONIC/i, + /TOKEN/i, + /PASSWORD/i, + /CREDENTIAL/i, + /ACCESS.?KEY/i, + /API.?KEY/i, + /AUTH/i, +]; + /** * Environment variable to force awal usage */ @@ -34,6 +75,8 @@ export interface AwalStatus { authenticated: boolean; /** Wallet address if authenticated */ address?: string; + /** Pinned awal version requested (from AWAL_VERSION constant, not detected from CLI) */ + version?: string; /** Error message if check failed */ error?: string; } @@ -79,6 +122,76 @@ export function isAwalForced(): boolean { return value === 'true' || value === '1'; } +/** + * Validate a URL before passing to awal CLI. + * Only allows https:// URLs by default to prevent file:// or other protocol abuse. + * When NULLPATH_ALLOW_HTTP=true is set (regardless of NODE_ENV), also allows + * http://localhost and http://127.0.0.1 for local development and testing. + */ +function validateUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new AwalPaymentError(`Invalid URL: ${url}`); + } + if (parsed.protocol === 'https:') { + return; // Always allowed + } + // Allow http for localhost in development + if ( + parsed.protocol === 'http:' && + (process.env.NULLPATH_ALLOW_HTTP === 'true' || process.env.NULLPATH_ALLOW_HTTP === '1') && + (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') + ) { + console.warn('[nullpath-mcp] WARNING: NULLPATH_ALLOW_HTTP is active — HTTP allowed for localhost. Do not use in production.'); + return; + } + throw new AwalPaymentError( + `Only HTTPS URLs are allowed, got: ${parsed.protocol}${parsed.protocol === 'http:' ? ' (set NULLPATH_ALLOW_HTTP=true for localhost dev)' : ''}` + ); +} + +/** + * Validate header key/value pairs to prevent injection via headers. + * Header names must be alphanumeric with hyphens or underscores. Values must not contain newlines. + */ +function validateHeader(key: string, value: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(key)) { + throw new AwalPaymentError(`Invalid header name: ${key}`); + } + if (/[\r\n]/.test(value)) { + throw new AwalPaymentError(`Header value contains newline characters: ${key}`); + } +} + +/** + * Build a sanitized env object for the child process. + * Strips sensitive vars (private keys, secrets, tokens, credentials) to prevent + * leaking to awal subprocess. Uses regex patterns for broad matching. + */ +function getSafeEnv(): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERNS.some(p => p.test(key))) { + env[key] = value; + } + } + env.NO_COLOR = '1'; + return env; +} + +/** + * Get the max payment amount in USDC atomic units. + */ +function getMaxPayment(): string { + const envMax = process.env.NULLPATH_MAX_PAYMENT; + if (envMax && /^\d+$/.test(envMax)) { + return envMax; + } + return DEFAULT_MAX_PAYMENT; +} + /** * Check awal CLI status (availability and authentication) * @@ -95,9 +208,9 @@ export async function checkAwalStatus(): Promise { try { // Check if awal is available and get status // Use execFileAsync to avoid shell interpretation - const { stdout } = await execFileAsync('npx', ['awal@latest', 'status', '--json'], { + const { stdout } = await execFileAsync('npx', [AWAL_PACKAGE, 'status', '--json'], { timeout: 15_000, // 15 second timeout for npx - env: { ...process.env, NO_COLOR: '1' }, + env: getSafeEnv(), }); const status = JSON.parse(stdout.trim()); @@ -106,6 +219,7 @@ export async function checkAwalStatus(): Promise { available: true, authenticated: status.authenticated === true || status.loggedIn === true, address: status.address || status.walletAddress, + version: AWAL_VERSION, }; awalStatusCacheTime = Date.now(); return awalStatusCache; @@ -163,8 +277,10 @@ export function clearAwalCache(): void { * * Uses execFile with argument array to prevent shell injection attacks. * All arguments are passed directly to npx without shell interpretation. + * URLs are validated to HTTPS-only. Headers are sanitized. + * A max payment amount is enforced as a safety cap. * - * @param url - The URL to call + * @param url - The URL to call (must be HTTPS) * @param options - Request options (method, body, headers) * @returns AwalPaymentResponse with result or error */ @@ -178,27 +294,41 @@ export async function awalPay( ): Promise { const { method = 'GET', body, headers } = options; + // Validate URL — only HTTPS allowed + validateUrl(url); + // Build argument array for execFile (no shell interpretation) - // This prevents command injection attacks from malicious URLs - const args: string[] = ['awal@latest', 'x402', 'pay', url]; + const args: string[] = [AWAL_PACKAGE, 'x402', 'pay', url]; - // Add method + // Add method (whitelist valid HTTP methods) if (method !== 'GET') { - args.push('-X', method); + const upperMethod = method.toUpperCase(); + const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + if (!ALLOWED_METHODS.includes(upperMethod)) { + throw new AwalPaymentError(`Invalid HTTP method: ${method}. Allowed: ${ALLOWED_METHODS.join(', ')}`); + } + args.push('-X', upperMethod); } - // Add body + // Add body (with size limit to prevent OOM) if (body) { + if (Buffer.byteLength(body, 'utf-8') > MAX_BODY_BYTES) { + throw new AwalPaymentError(`Request body exceeds ${MAX_BODY_BYTES} byte limit`); + } args.push('-d', body); } - // Add headers + // Add headers (validated) if (headers) { for (const [key, value] of Object.entries(headers)) { + validateHeader(key, value); args.push('-H', `${key}: ${value}`); } } + // Enforce max payment amount as safety cap + args.push('--max-amount', getMaxPayment()); + // Request JSON output args.push('--json'); @@ -207,7 +337,7 @@ export async function awalPay( // Arguments are passed directly without shell interpretation const { stdout, stderr } = await execFileAsync('npx', args, { timeout: 60_000, // 60 second timeout for payment - env: { ...process.env, NO_COLOR: '1' }, + env: getSafeEnv(), maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large responses }); @@ -241,16 +371,16 @@ export async function awalPay( body: response.body || response.data || response, statusCode: typeof response.statusCode === 'number' ? response.statusCode : 200, payment: { - amount: typeof (response.payment as Record)?.amount === 'string' - ? (response.payment as Record).amount as string + amount: typeof (response.payment as Record)?.amount === 'string' + ? (response.payment as Record).amount as string : undefined, recipient: typeof (response.payment as Record)?.recipient === 'string' ? (response.payment as Record).recipient as string : undefined, transactionHash: typeof (response.payment as Record)?.transactionHash === 'string' ? (response.payment as Record).transactionHash as string - : typeof response.txHash === 'string' - ? response.txHash + : typeof response.txHash === 'string' + ? response.txHash : undefined, }, }; @@ -289,6 +419,13 @@ export async function awalPay( } } +/** + * Get the pinned awal version + */ +export function getAwalVersion(): string { + return AWAL_VERSION; +} + /** * Get the awal wallet address (if authenticated) * @@ -297,4 +434,4 @@ export async function awalPay( export async function getAwalAddress(): Promise { const status = await checkAwalStatus(); return status.address || null; -} +} \ No newline at end of file