From 6f0b21c50477d8c9ea49e31cfbf11b8f8d4dc586 Mon Sep 17 00:00:00 2001 From: Tony Gaeta Date: Sat, 21 Feb 2026 12:50:32 -0500 Subject: [PATCH 1/6] fix: security hardening and package cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin awal to v2.0.3 (was @latest — supply chain risk) - Add URL validation (HTTPS-only) before passing to awal CLI - Add header sanitization (no newlines, alphanumeric names only) - Add --max-amount safety cap on awal payments ($10 default, configurable via NULLPATH_MAX_PAYMENT) - Add HTTP method sanitization (strip non-alpha chars) - Trim published package via files field (50→23 files, 263KB→112KB) - Remove src/, .github/, docs/, dist/__tests__/ from npm tarball - Wire semantic-release/git + changelog for auto version bump in repo - Sync package.json version to 1.4.1 (matches npm) - Update tests for pinned version --- .releaserc.json | 10 +++- package.json | 12 ++++- src/__tests__/awal.test.ts | 4 +- src/lib/awal.ts | 100 +++++++++++++++++++++++++++++++++---- 4 files changed, 111 insertions(+), 15 deletions(-) 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..5c0fa71 100644 --- a/src/__tests__/awal.test.ts +++ b/src/__tests__/awal.test.ts @@ -143,7 +143,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 +354,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) ); }); diff --git a/src/lib/awal.ts b/src/lib/awal.ts index 6789590..ed82722 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,19 @@ 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'; + /** * Environment variable to force awal usage */ @@ -34,6 +53,8 @@ export interface AwalStatus { authenticated: boolean; /** Wallet address if authenticated */ address?: string; + /** awal version detected */ + version?: string; /** Error message if check failed */ error?: string; } @@ -79,6 +100,46 @@ export function isAwalForced(): boolean { return value === 'true' || value === '1'; } +/** + * Validate a URL before passing to awal CLI. + * Only allows https:// URLs to prevent file:// or other protocol abuse. + */ +function validateUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new AwalPaymentError(`Invalid URL: ${url}`); + } + if (parsed.protocol !== 'https:') { + throw new AwalPaymentError(`Only HTTPS URLs are allowed, got: ${parsed.protocol}`); + } +} + +/** + * Validate header key/value pairs to prevent injection via headers. + * Header names must be alphanumeric + hyphens. 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}`); + } +} + +/** + * 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,7 +156,7 @@ 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' }, }); @@ -106,6 +167,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 +225,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,13 +242,16 @@ 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 if (method !== 'GET') { - args.push('-X', method); + const sanitizedMethod = method.toUpperCase().replace(/[^A-Z]/g, ''); + args.push('-X', sanitizedMethod); } // Add body @@ -192,13 +259,17 @@ export async function awalPay( 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'); @@ -241,16 +312,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 +360,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 +375,4 @@ export async function awalPay( export async function getAwalAddress(): Promise { const status = await checkAwalStatus(); return status.address || null; -} +} \ No newline at end of file From 3737d648943588f007e272c0dc7ad9dc10f62085 Mon Sep 17 00:00:00 2001 From: Tony Gaeta Date: Sat, 21 Feb 2026 13:09:11 -0500 Subject: [PATCH 2/6] fix: address Copilot review feedback - Allow underscores in header names (RFC 7230 permits them) - Replace HTTP method char-stripping with whitelist validation - Allow http://localhost and http://127.0.0.1 when NULLPATH_ALLOW_HTTP=true - Add 18 new tests for validation functions, getAwalVersion, max-amount cap - Total: 65 tests passing (was 47) --- src/__tests__/awal.test.ts | 168 +++++++++++++++++++++++++++++++++++++ src/lib/awal.ts | 32 +++++-- 2 files changed, 192 insertions(+), 8 deletions(-) diff --git a/src/__tests__/awal.test.ts b/src/__tests__/awal.test.ts index 5c0fa71..a05d37a 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', () => { @@ -383,4 +384,171 @@ 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); + delete process.env.NULLPATH_ALLOW_HTTP; + }); + + 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); + delete process.env.NULLPATH_ALLOW_HTTP; + }); + + 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'); + delete process.env.NULLPATH_ALLOW_HTTP; + }); + }); + + 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('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'); + delete process.env.NULLPATH_MAX_PAYMENT; + }); + + 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 + delete process.env.NULLPATH_MAX_PAYMENT; + }); + }); }); diff --git a/src/lib/awal.ts b/src/lib/awal.ts index ed82722..4daf112 100644 --- a/src/lib/awal.ts +++ b/src/lib/awal.ts @@ -102,7 +102,8 @@ export function isAwalForced(): boolean { /** * Validate a URL before passing to awal CLI. - * Only allows https:// URLs to prevent file:// or other protocol abuse. + * Only allows https:// URLs in production to prevent file:// or other protocol abuse. + * In development (NULLPATH_ALLOW_HTTP=true), also allows http://localhost and http://127.0.0.1. */ function validateUrl(url: string): void { let parsed: URL; @@ -111,17 +112,28 @@ function validateUrl(url: string): void { } catch { throw new AwalPaymentError(`Invalid URL: ${url}`); } - if (parsed.protocol !== 'https:') { - throw new AwalPaymentError(`Only HTTPS URLs are allowed, got: ${parsed.protocol}`); + 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') + ) { + 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 + hyphens. Values must not contain newlines. + * 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)) { + if (!/^[a-zA-Z0-9_-]+$/.test(key)) { throw new AwalPaymentError(`Invalid header name: ${key}`); } if (/[\r\n]/.test(value)) { @@ -248,10 +260,14 @@ export async function awalPay( // Build argument array for execFile (no shell interpretation) const args: string[] = [AWAL_PACKAGE, 'x402', 'pay', url]; - // Add method + // Add method (whitelist valid HTTP methods) if (method !== 'GET') { - const sanitizedMethod = method.toUpperCase().replace(/[^A-Z]/g, ''); - args.push('-X', sanitizedMethod); + 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 From 25330578e84c3d96b6a7e36472235616971372dc Mon Sep 17 00:00:00 2001 From: Tony Gaeta Date: Sat, 21 Feb 2026 13:45:25 -0500 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20red=20team=20findings=20=E2=80=94=20?= =?UTF-8?q?env=20sanitization,=20body=20limit,=20HTTP=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter NULLPATH_WALLET_KEY and other sensitive vars from child process env - Add 1MB body size limit to prevent OOM via large payloads - Log warning when NULLPATH_ALLOW_HTTP is active - 3 new tests (body limit, env sanitization, normal body) - Total: 68 tests passing --- src/__tests__/awal.test.ts | 39 ++++++++++++++++++++++++++++++++++++++ src/lib/awal.ts | 35 +++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/__tests__/awal.test.ts b/src/__tests__/awal.test.ts index a05d37a..c67aab2 100644 --- a/src/__tests__/awal.test.ts +++ b/src/__tests__/awal.test.ts @@ -506,6 +506,45 @@ describe('awal', () => { }); }); + 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'); + delete process.env.NULLPATH_WALLET_KEY; + }); + }); + describe('max payment cap', () => { it('passes --max-amount with default value', async () => { mockExecFileAsync.mockResolvedValueOnce({ diff --git a/src/lib/awal.ts b/src/lib/awal.ts index 4daf112..67ea8ff 100644 --- a/src/lib/awal.ts +++ b/src/lib/awal.ts @@ -31,6 +31,16 @@ const AWAL_PACKAGE = `awal@${AWAL_VERSION}`; */ 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; + +/** + * Environment variables that should NOT be passed to the awal child process. + */ +const SENSITIVE_ENV_VARS = ['NULLPATH_WALLET_KEY', 'PRIVATE_KEY', 'SECRET_KEY', 'MNEMONIC']; + /** * Environment variable to force awal usage */ @@ -121,6 +131,7 @@ function validateUrl(url: string): void { (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( @@ -141,6 +152,21 @@ function validateHeader(key: string, value: string): void { } } +/** + * Build a sanitized env object for the child process. + * Strips sensitive vars (private keys, secrets) to prevent leaking to awal subprocess. + */ +function getSafeEnv(): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_VARS.some(s => key.toUpperCase().includes(s.toUpperCase()))) { + env[key] = value; + } + } + env.NO_COLOR = '1'; + return env; +} + /** * Get the max payment amount in USDC atomic units. */ @@ -170,7 +196,7 @@ export async function checkAwalStatus(): Promise { // Use execFileAsync to avoid shell interpretation 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()); @@ -270,8 +296,11 @@ export async function awalPay( 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); } @@ -294,7 +323,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 }); From a8652c8f9b2c3d55fa40abba354e406c23c7e015 Mon Sep 17 00:00:00 2001 From: Tony Gaeta Date: Sat, 21 Feb 2026 14:05:54 -0500 Subject: [PATCH 4/6] fix: sync MCP server version constant to 1.4.1 Copilot correctly identified that src/index.ts had a hardcoded '1.2.0' while package.json is 1.4.1. Synced the constant. Going forward, semantic-release/git will bump package.json automatically. --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: { From 63fae02fc69e95b6845177c276d8c30453362e44 Mon Sep 17 00:00:00 2001 From: Tony Gaeta Date: Sat, 21 Feb 2026 14:20:57 -0500 Subject: [PATCH 5/6] fix: address Copilot round 2 feedback - Fix AwalStatus.version docstring to clarify it's pinned, not detected - Fix validateUrl comment to match actual behavior (env var, not NODE_ENV) - Broaden env sanitization to regex patterns (catches AWS_SECRET_ACCESS_KEY, GH_TOKEN, etc.) - Move env var cleanup to afterEach for test reliability - Add test for broader secret pattern matching - Total: 69 tests passing --- src/__tests__/awal.test.ts | 36 ++++++++++++++++++++++++++++++------ src/lib/awal.ts | 28 +++++++++++++++++++++------- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/__tests__/awal.test.ts b/src/__tests__/awal.test.ts index c67aab2..fc78c4b 100644 --- a/src/__tests__/awal.test.ts +++ b/src/__tests__/awal.test.ts @@ -34,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', () => { @@ -428,7 +431,6 @@ describe('awal', () => { const result = await awalPay('http://localhost:8787/api/v1/execute'); expect(result.success).toBe(true); - delete process.env.NULLPATH_ALLOW_HTTP; }); it('allows http://127.0.0.1 when NULLPATH_ALLOW_HTTP=true', async () => { @@ -440,13 +442,11 @@ describe('awal', () => { const result = await awalPay('http://127.0.0.1:8787/api/v1/execute'); expect(result.success).toBe(true); - delete process.env.NULLPATH_ALLOW_HTTP; }); 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'); - delete process.env.NULLPATH_ALLOW_HTTP; }); }); @@ -541,7 +541,33 @@ describe('awal', () => { const envPassed = mockExecFileAsync.mock.calls[0][2].env; expect(envPassed.NULLPATH_WALLET_KEY).toBeUndefined(); expect(envPassed.NO_COLOR).toBe('1'); - delete process.env.NULLPATH_WALLET_KEY; + }); + + 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; }); }); @@ -572,7 +598,6 @@ describe('awal', () => { const args = mockExecFileAsync.mock.calls[0][1]; const maxIdx = args.indexOf('--max-amount'); expect(args[maxIdx + 1]).toBe('5000000'); - delete process.env.NULLPATH_MAX_PAYMENT; }); it('falls back to default for invalid NULLPATH_MAX_PAYMENT', async () => { @@ -587,7 +612,6 @@ describe('awal', () => { const args = mockExecFileAsync.mock.calls[0][1]; const maxIdx = args.indexOf('--max-amount'); expect(args[maxIdx + 1]).toBe('10000000'); // Falls back to default - delete process.env.NULLPATH_MAX_PAYMENT; }); }); }); diff --git a/src/lib/awal.ts b/src/lib/awal.ts index 67ea8ff..7299360 100644 --- a/src/lib/awal.ts +++ b/src/lib/awal.ts @@ -37,9 +37,21 @@ const DEFAULT_MAX_PAYMENT = '10000000'; const MAX_BODY_BYTES = 1_048_576; /** - * Environment variables that should NOT be passed to the awal child process. + * 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_VARS = ['NULLPATH_WALLET_KEY', 'PRIVATE_KEY', 'SECRET_KEY', 'MNEMONIC']; +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 @@ -63,7 +75,7 @@ export interface AwalStatus { authenticated: boolean; /** Wallet address if authenticated */ address?: string; - /** awal version detected */ + /** Pinned awal version requested (from AWAL_VERSION constant, not detected from CLI) */ version?: string; /** Error message if check failed */ error?: string; @@ -112,8 +124,9 @@ export function isAwalForced(): boolean { /** * Validate a URL before passing to awal CLI. - * Only allows https:// URLs in production to prevent file:// or other protocol abuse. - * In development (NULLPATH_ALLOW_HTTP=true), also allows http://localhost and http://127.0.0.1. + * 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; @@ -154,12 +167,13 @@ function validateHeader(key: string, value: string): void { /** * Build a sanitized env object for the child process. - * Strips sensitive vars (private keys, secrets) to prevent leaking to awal subprocess. + * 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_VARS.some(s => key.toUpperCase().includes(s.toUpperCase()))) { + if (value !== undefined && !SENSITIVE_ENV_PATTERNS.some(p => p.test(key))) { env[key] = value; } } From 57d1c4a7e3eca6d648d8cd182774052c2ddc8d4c Mon Sep 17 00:00:00 2001 From: Tony Gaeta Date: Sat, 21 Feb 2026 14:23:38 -0500 Subject: [PATCH 6/6] ci: add PR workflow for build + test --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml 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