From 4f76b3d88a5393f853cfd7b68bdc418314cee30c Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 23 May 2026 13:52:19 -0400 Subject: [PATCH] Security hardening: fix RCE, persist spend guard, validate inputs - publish.js: replace execSync shell interpolation with spawnSync argv array, eliminating command injection / RCE surface (CRITICAL) - payment.js: validate payTo is a real Ethereum address via isAddress() before sending funds; validate amount is a positive finite number before spend limit check; strip non-numeric chars from price string - memory.js: add agent_spend table + recordSpend/getSpendToday so the $10/day limit persists across process restarts - payment.js: use ClickHouse-backed spend tracking instead of in-memory - index.js: fail fast at startup if any required env var is missing - server.js: verify x-payment-proof tx is confirmed on Base Sepolia before serving search results (not just regex format check) Co-Authored-By: Claude Sonnet 4.6 --- index.js | 18 ++++++++++++++++++ memory.js | 32 +++++++++++++++++++++++++++++++- payment.js | 34 ++++++++++++++++++++-------------- publish.js | 26 +++++++++++++++----------- server.js | 13 +++++++++++++ 5 files changed, 97 insertions(+), 26 deletions(-) diff --git a/index.js b/index.js index d16f470..00006c5 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,22 @@ require('dotenv').config(); + +const REQUIRED_ENV = [ + 'ANTHROPIC_API_KEY', + 'NIMBLE_API_KEY', + 'WALLET_PRIVATE_KEY', + 'ZERODEV_PROJECT_ID', + 'ZERODEV_RPC_URL', + 'CLICKHOUSE_HOST', + 'CLICKHOUSE_USER', + 'CLICKHOUSE_PASSWORD', +]; + +const missing = REQUIRED_ENV.filter((k) => !process.env[k]); +if (missing.length > 0) { + console.error(`[startup] Missing required env vars: ${missing.join(', ')}`); + process.exit(1); +} + const { runAgent } = require('./agent'); const prompt = process.argv.slice(2).join(' ') || 'Find me the best web data API subscription under $10 and buy it'; diff --git a/memory.js b/memory.js index 548bea1..c916f10 100644 --- a/memory.js +++ b/memory.js @@ -27,6 +27,15 @@ async function ensureTable() { ORDER BY timestamp `, }); + await getClient().exec({ + query: ` + CREATE TABLE IF NOT EXISTS agent_spend ( + timestamp DateTime DEFAULT now(), + amount_usd Float64 + ) ENGINE = MergeTree() + ORDER BY timestamp + `, + }); } async function logPurchase({ query, selectedResult, price, txHash, sourceUrl }) { @@ -57,4 +66,25 @@ async function getRecentPurchases(limit = 10) { return await result.json(); } -module.exports = { logPurchase, getRecentPurchases }; +async function recordSpend(amountUSD) { + await ensureTable(); + await getClient().insert({ + table: 'agent_spend', + values: [{ amount_usd: amountUSD }], + format: 'JSONEachRow', + }); +} + +async function getSpendToday() { + await ensureTable(); + const today = new Date().toISOString().slice(0, 10); + const result = await getClient().query({ + query: `SELECT sum(amount_usd) as total FROM agent_spend WHERE toDate(timestamp) = {today:String}`, + query_params: { today }, + format: 'JSONEachRow', + }); + const rows = await result.json(); + return parseFloat(rows[0]?.total ?? 0); +} + +module.exports = { logPurchase, getRecentPurchases, recordSpend, getSpendToday }; diff --git a/payment.js b/payment.js index 0da64f8..b4bc6ca 100644 --- a/payment.js +++ b/payment.js @@ -1,9 +1,10 @@ -const { createPublicClient, http, parseUnits, encodeFunctionData } = require('viem'); +const { createPublicClient, http, parseUnits, encodeFunctionData, isAddress } = require('viem'); const { baseSepolia } = require('viem/chains'); const { privateKeyToAccount } = require('viem/accounts'); const { createKernelAccountClient } = require('@zerodev/sdk'); const axios = require('axios'); const { withSpan, increment, gauge, timing } = require('./telemetry'); +const { getSpendToday, recordSpend } = require('./memory'); // Minimal ERC-20 transfer ABI const ERC20_TRANSFER_ABI = [ @@ -22,20 +23,16 @@ const ERC20_TRANSFER_ABI = [ // USDC on Base Sepolia (Circle's testnet deployment) const USDC_ADDRESS = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; -// Daily spend guard (in-memory for demo — production would use on-chain policy) -const spendTracker = { date: null, total: 0 }; const MAX_DAILY_USD = 10; -function checkSpendLimit(amountUSD) { - const today = new Date().toISOString().slice(0, 10); - if (spendTracker.date !== today) { - spendTracker.date = today; - spendTracker.total = 0; +async function checkSpendLimit(amountUSD) { + if (isNaN(amountUSD) || amountUSD <= 0) { + throw new Error(`Invalid payment amount: ${amountUSD}`); } - if (spendTracker.total + amountUSD > MAX_DAILY_USD) { - throw new Error(`Daily spend limit of $${MAX_DAILY_USD} would be exceeded (used: $${spendTracker.total})`); + const spentToday = await getSpendToday(); + if (spentToday + amountUSD > MAX_DAILY_USD) { + throw new Error(`Daily spend limit of $${MAX_DAILY_USD} would be exceeded (used: $${spentToday.toFixed(2)})`); } - spendTracker.total += amountUSD; } function getWalletClient() { @@ -83,10 +80,14 @@ async function getWalletAddress() { async function handle402Payment(paymentInfo) { const { payTo, amount, token, chain } = paymentInfo; + if (!isAddress(payTo)) { + throw new Error(`Invalid payTo address: ${payTo}`); + } + console.log(`[payment] 402 received — paying ${amount} ${token} to ${payTo} on ${chain}`); const amountUSD = parseFloat(amount); - checkSpendLimit(amountUSD); + await checkSpendLimit(amountUSD); return withSpan('payment.transaction', { token, chain, amount }, async () => { const { account, walletClient, publicClient } = getWalletClient(); @@ -120,9 +121,11 @@ async function handle402Payment(paymentInfo) { try { await publicClient.waitForTransactionReceipt({ hash: txHash }); const confirmMs = Date.now() - confirmStart; + await recordSpend(amountUSD); + const spentToday = await getSpendToday(); timing('payment.confirmation_ms', confirmMs, { token, chain }); gauge('payment.amount_usd', amountUSD, { token, chain }); - gauge('payment.daily_spend_usd', spendTracker.total); + gauge('payment.daily_spend_usd', spentToday); increment('payment.tx.confirmed', { token, chain }); } catch (err) { increment('payment.tx.error', { token, chain, reason: 'confirmation_failed' }); @@ -161,9 +164,12 @@ async function fetchWithPayment(url, headers = {}) { async function mockPaymentFlow(selectedResult, price) { console.log(`[payment] Simulating 402 payment for: ${selectedResult}`); + const rawAmount = parseFloat(price.replace(/[^0-9.]/g, '')); + const amount = (!isNaN(rawAmount) && rawAmount > 0) ? rawAmount.toFixed(2) : '1.00'; + const mockPaymentInfo = { payTo: '0x742d35Cc6634C0532925a3b8D4C9C2b5b2B2b2b2', - amount: price.replace('$', '').replace('/mo', '').trim() || '1.00', + amount, token: 'USDC', chain: 'base-sepolia', }; diff --git a/publish.js b/publish.js index 2153bd8..dcc6252 100644 --- a/publish.js +++ b/publish.js @@ -1,4 +1,4 @@ -const { execSync } = require('child_process'); +const { spawnSync } = require('child_process'); async function publishReceipt({ query, selectedResult, price, txHash, sourceUrl, searchResults }) { const apiKey = process.env.SENSO_API_KEY; @@ -10,7 +10,8 @@ async function publishReceipt({ query, selectedResult, price, txHash, sourceUrl, // Create a Senso prompt tied to this purchase so we have a geo_question_id to publish against const promptJson = senso( - `prompts create --data '${JSON.stringify({ question_text: questionText, type: 'decision' })}'`, + 'prompts create', + ['--data', JSON.stringify({ question_text: questionText, type: 'decision' })], apiKey ); const promptId = promptJson.prompt_id ?? promptJson.id; @@ -18,12 +19,13 @@ async function publishReceipt({ query, selectedResult, price, txHash, sourceUrl, // Publish the receipt as a citeable on cited.md const publishJson = senso( - `engine publish --data '${JSON.stringify({ + 'engine publish', + ['--data', JSON.stringify({ geo_question_id: promptId, raw_markdown: markdown, seo_title: seoTitle, summary: `Shop3 autonomously purchased ${selectedResult} for ${price}. Tx: ${txHash}`, - })}'`, + })], apiKey ); @@ -32,16 +34,18 @@ async function publishReceipt({ query, selectedResult, price, txHash, sourceUrl, return url; } -// Run a senso CLI command and return parsed JSON output -function senso(args, apiKey) { - const result = execSync(`senso ${args} --output json --quiet`, { +// Run a senso CLI command and return parsed JSON output. +// Uses spawnSync with an explicit argv array — no shell interpolation, no injection surface. +function senso(subcommand, flags, apiKey) { + const argv = [...subcommand.split(' '), ...flags, '--output', 'json', '--quiet']; + const result = spawnSync('senso', argv, { env: { ...process.env, SENSO_API_KEY: apiKey }, encoding: 'utf8', }); - // Strip any ANSI escape codes and redact the API key if it appears in the output - let sanitized = result.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); - sanitized = sanitized.replace(new RegExp(apiKey, 'g'), (m) => m.slice(0, 10) + '...').trim(); - return JSON.parse(sanitized); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`senso exited ${result.status}: ${result.stderr}`); + const clean = result.stdout.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim(); + return JSON.parse(clean); } function buildReceiptMarkdown({ query, selectedResult, price, txHash, sourceUrl, searchResults }) { diff --git a/server.js b/server.js index 763260d..d94e272 100644 --- a/server.js +++ b/server.js @@ -2,8 +2,12 @@ require('dotenv').config(); process.env.SERVER_MODE = 'true'; const express = require('express'); +const { createPublicClient, http } = require('viem'); +const { baseSepolia } = require('viem/chains'); const { searchWeb } = require('./search'); +const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); + const app = express(); const PORT = parseInt(process.env.SERVER_PORT, 10) || 3000; const PAYMENT_PAYTO = process.env.SEARCH_PAYMENT_ADDRESS || '0x1111111111111111111111111111111111111111'; @@ -53,6 +57,15 @@ app.get('/search', async (req, res) => { }); } + try { + const receipt = await publicClient.getTransactionReceipt({ hash: paymentProof }); + if (!receipt || receipt.status !== 'success') { + return res.status(402).json({ error: 'Payment transaction not confirmed on-chain' }); + } + } catch { + return res.status(402).json({ error: 'Could not verify payment transaction on-chain' }); + } + try { const numResults = Number(req.query.num_results) || 5; const results = await searchWeb(query, numResults);