diff --git a/REVIEW.md b/REVIEW.md index 0024ac1..05e10b5 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -590,10 +590,69 @@ Verified on a real Alpine 3.21 machine, driving the compiled binary through a PT Added to the release matrix, so Alpine-based OpenClaw containers get a working binary. -## Still open (not fixed) +## Resolved in the third pass -**Payments are received but never recorded.** `src/routes/payment.js` handles `order_created` +**Payments are received but never recorded** — the payment surface was removed entirely. Original finding: `src/routes/payment.js` handles `order_created` with `// TODO: Mark fix as paid in database`. With Lemon Squeezy configured a user can be charged $2 and nothing in the system records it or unlocks anything. The checkout path is otherwise sound. This needs a product decision — record and gate on payment, or remove the paid path until it is real — so it is flagged rather than guessed at. + + +--- + +# Third pass — payment removal and real break-fix scenarios + +## Payment surface removed + +`/api/checkout` created real Lemon Squeezy sessions while `order_created` was +`// TODO: Mark fix as paid in database`, so a configured store could charge $2 and record +nothing. Removed the checkout endpoint, the payment page, the payment webhook and the unused +Lemon Squeezy verifier. Svix verification stays — the Resend inbound-email webhook uses it. +`security-regressions` now asserts those routes stay 404. + +## Five break-fix scenarios on a real OpenClaw 2026.6.11 install + +Each scenario resets the box to a valid config, breaks one real thing, and scans. + +| # | Broken | ClawFix reported | Detected | +|---|---|---|---| +| 1 | Gateway process killed | `[critical] Gateway is not running` | **yes** | +| 2 | Port 18789 held by an unrelated process | `[critical] Port conflict detected` + `Gateway port 18789 is occupied by python3 (PID 726), but OpenClaw cannot reach it` | **yes** | +| 3 | `openclaw.json` corrupted | `[high] JSON5 parse failed: SyntaxError: JSON5: invalid character 'i' at 1:8` | **yes** | +| 4 | Gateway auth set to `none` on loopback | `[critical] Gateway auth missing on loopback` + `[medium] Gateway HTTP APIs are reachable without auth` | **yes** | +| 5 | `update.auto.enabled = true` | `[medium] Auto-update enabled (risk of restart loops)` | **yes** | + +**Detection: 5/5.** Scenario 2 is notably good — it names the squatting process and PID. + +## Repairing: the honest picture + +The guarded-repair pipeline ran end to end against the real install: plan built locally, +`invalid_token` rejected, single-use token enforced (`token_reused`), apply executed the real +`openclaw gateway restart`, verify re-checked the port, and the outcome was reported as +`verify_failed` — with a rescan agreeing the gateway was still down. + +That is the correct behaviour, and it is the B-24 fix working: before this pass the same run +would have claimed `applied`. + +But it is not a fix, and the reason matters: + +**ClawFix ships exactly one executable repair, and it cannot work in a container.** +`gateway-not-running` invokes `openclaw gateway restart`, which needs systemd or launchd. On +this Alpine host OpenClaw answered *"Gateway service disabled … systemd user services are +unavailable"*, so the repair could not succeed no matter how correct the plumbing is. Nothing in +the catalog can repair scenarios 2–5 either: the four other real problems ClawFix detects have +no repair at all. + +So today ClawFix is a strong **diagnostic** with one repair that only applies on service-managed +hosts. The site and README should say that plainly, and the catalog is the obvious place to +invest next — port-conflict and config-invalid are both detected precisely enough to repair. + +## Two quality issues seen only in real output + +- A finding surfaced as `[critical] timeout` with no other context — a native collector timing + out is being presented to the user as a critical OpenClaw problem + (`cli/core/diagnostics.js`, `text: finding.title || finding.message`). +- Raw parser output is used as a user-facing title: `JSON5 parse failed: SyntaxError: JSON5: + invalid character 'i' at 1:8`. Accurate, but it is a stack-trace fragment where a sentence + belongs. diff --git a/scripts/prove-remediation.mjs b/scripts/prove-remediation.mjs index 0ca2d60..4885935 100644 --- a/scripts/prove-remediation.mjs +++ b/scripts/prove-remediation.mjs @@ -53,15 +53,17 @@ await check('unknown Codex findings cannot inherit unrelated repairs', async () }]), []); }); -await check('results and payment routes validate fix IDs', async () => { - const [results, payment] = await Promise.all([ - text('src/routes/results.js'), - text('src/routes/payment.js'), - ]); +await check('results route validates fix IDs and the payment surface stays removed', async () => { + const results = await text('src/routes/results.js'); assert.match(results, /validateFixId\(req\.params\.fixId\)/); assert.match(results, /escapeHtml\(data\.analysis/); - assert.match(payment, /validateFixId\(req\.params\.fixId\)/); - assert.match(payment, /validateFixId\(req\.body\?\.fixId\)/); + + // Payments were removed: checkout created real Lemon Squeezy sessions while the + // order_created handler was a TODO, so a store could charge and record nothing. + const { existsSync } = await import('node:fs'); + assert.equal(existsSync(new URL('../src/routes/payment.js', import.meta.url)), false); + const server = await text('src/server.js'); + assert.doesNotMatch(server, /paymentRouter/); }); await check('CLI requires consent and recursively redacts every upload', async () => { diff --git a/src/landing.js b/src/landing.js index 963a8ad..cad7804 100644 --- a/src/landing.js +++ b/src/landing.js @@ -612,7 +612,7 @@ gateway-restart repair. Want me to?
3

Review & Apply

-

You get a commented fix script. Read it, understand it, then run it. Nothing happens without your approval.

+

You get a commented fix script. Read it, understand it, then run it. Nothing happens without your approval. One reviewed repair runs automatically today — restarting a stopped gateway on a host with systemd or launchd; everything else is diagnosis and guidance.

@@ -767,7 +767,7 @@ gateway-restart repair. Want me to? Hosted
Free

MIT source, optional hosting

-

The CLI and server are MIT licensed. clawfix.dev is currently free to use.

+

The CLI and server are MIT licensed. clawfix.dev is free to use, with no paid tier and no payment path in the product.

Hosted limits may change. Self-hosting remains available under the MIT license.

diff --git a/src/routes/payment.js b/src/routes/payment.js deleted file mode 100644 index e4c14e3..0000000 --- a/src/routes/payment.js +++ /dev/null @@ -1,245 +0,0 @@ -import { Router } from 'express'; -import { validateFixId } from '../../cli/bin/security.js'; -import { verifyLemonSqueezySignature } from '../webhook-signatures.js'; - -export const paymentRouter = Router(); - -/** - * Payment integration — Lemon Squeezy for card payments, x402 for USDC on Base. - * - * Flow: - * 1. User gets a fix (free for pattern-matching, paid for AI analysis) - * 2. Results page shows payment button - * 3. Clicking opens Lemon Squeezy checkout overlay or x402 flow - * 4. Webhook confirms payment - * - * Environment variables: - * LEMONSQUEEZY_API_KEY — API key from Lemon Squeezy dashboard - * LEMONSQUEEZY_STORE_ID — Store ID - * LEMONSQUEEZY_VARIANT_ID — Product variant ID for "AI Fix" ($2) - * LEMONSQUEEZY_WEBHOOK_SECRET — Webhook signing secret - * PAYMENT_WALLET — Wallet address for USDC payments - */ - -const LS_CONFIG = { - apiKey: process.env.LEMONSQUEEZY_API_KEY, - storeId: process.env.LEMONSQUEEZY_STORE_ID, - variantId: process.env.LEMONSQUEEZY_VARIANT_ID, - webhookSecret: process.env.LEMONSQUEEZY_WEBHOOK_SECRET, -}; - -const configuredWallet = process.env.PAYMENT_WALLET || ''; -const WALLET = /^0x[a-fA-F0-9]{40}$/.test(configuredWallet) ? configuredWallet : ''; - -// Create a checkout session for a fix -paymentRouter.post('/checkout', async (req, res) => { - const fixId = validateFixId(req.body?.fixId); - - if (!fixId) { - return res.status(400).json({ error: 'A valid fixId is required' }); - } - - if (!LS_CONFIG.apiKey || !LS_CONFIG.storeId || !LS_CONFIG.variantId) { - return res.json({ - checkoutUrl: null, - message: 'Payment not yet configured. Fix is free during beta!', - walletAddress: WALLET, - chain: 'Base', - amount: '2 USDC', - }); - } - - try { - // Create Lemon Squeezy checkout - const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${LS_CONFIG.apiKey}`, - 'Content-Type': 'application/vnd.api+json', - 'Accept': 'application/vnd.api+json', - }, - body: JSON.stringify({ - data: { - type: 'checkouts', - attributes: { - custom_price: null, // Use variant price - product_options: { - name: `ClawFix AI Diagnosis — ${fixId}`, - description: 'AI-powered diagnostic and fix script for your OpenClaw installation.', - }, - checkout_options: { - dark: true, - logo: false, - }, - checkout_data: { - custom: { - fix_id: fixId, - }, - }, - expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24h expiry - }, - relationships: { - store: { data: { type: 'stores', id: LS_CONFIG.storeId } }, - variant: { data: { type: 'variants', id: LS_CONFIG.variantId } }, - }, - }, - }), - }); - - if (!response.ok) { - const err = await response.text(); - console.error('Lemon Squeezy error:', err); - throw new Error('Payment provider error'); - } - - const data = await response.json(); - const checkoutUrl = data.data.attributes.url; - - res.json({ - checkoutUrl, - fixId, - // Also offer crypto option - crypto: { - walletAddress: WALLET, - chain: 'Base', - amount: '2', - currency: 'USDC', - }, - }); - } catch (error) { - console.error('Checkout error:', error.message); - res.status(500).json({ - error: 'Failed to create checkout', - message: 'Try paying with USDC instead', - crypto: { - walletAddress: WALLET, - chain: 'Base', - amount: '2', - currency: 'USDC', - }, - }); - } -}); - -// Lemon Squeezy webhook handler -paymentRouter.post('/webhook/lemonsqueezy', async (req, res) => { - // Fail closed: an unsigned request is never a payment notification, and an unconfigured - // secret must not turn this into an open endpoint that logs whatever it is sent. - const verified = verifyLemonSqueezySignature({ - secret: LS_CONFIG.webhookSecret, - rawBody: req.rawBody, - signature: req.headers['x-signature'], - }); - if (!verified.ok) { - console.warn(`Rejected Lemon Squeezy webhook: ${verified.reason}`); - return res.status(401).json({ error: 'Invalid signature' }); - } - - const event = req.headers['x-event-name']; - const data = req.body; - - console.log(`Lemon Squeezy webhook: ${event}`); - - if (event === 'order_created') { - const fixId = data.meta?.custom_data?.fix_id; - const amount = data.data?.attributes?.total_formatted; - console.log(`💰 Payment received! Fix: ${fixId}, Amount: ${amount}`); - // TODO: Mark fix as paid in database - } - - res.json({ received: true }); -}); - -// Payment status page -paymentRouter.get('/pay/:fixId', (req, res) => { - const fixId = validateFixId(req.params.fixId); - if (!fixId) return res.status(400).type('text/plain').send('Invalid fix ID'); - const lsConfigured = !!(LS_CONFIG.apiKey && LS_CONFIG.storeId && LS_CONFIG.variantId); - - res.setHeader('Content-Type', 'text/html'); - res.send(paymentPage(fixId, lsConfigured)); -}); - -function paymentPage(fixId, lsConfigured) { - return ` - - - - - ClawFix — Pay for Fix ${fixId} - - - - -
-

🦞 ClawFix

- - ${!lsConfigured ? ` -
-

🎉 Free during beta!
All fixes are free while we're in beta. Enjoy!

-
-

Fix ID: ${fixId}

- ← View Your Fix - ` : ` -
$2
-

AI diagnosis + fix script for your OpenClaw

- - -

— or —

- - - `} - - -
- - -`; -} diff --git a/src/routes/results.js b/src/routes/results.js index c959cf8..9856017 100644 --- a/src/routes/results.js +++ b/src/routes/results.js @@ -222,11 +222,11 @@ function resultsPage(fixId) { html += ''; } - // Beta notice (no payment during beta) + // Free and open source — there is no paid tier. if (count > 0) { html += '
'; - html += '

🎉 Free During Beta!

'; - html += '

This diagnosis is on us. Enjoy full AI analysis for free while we\\'re in early access.
'; + html += '

🎉 Free and open source

'; + html += '

ClawFix is MIT licensed and free to use.
'; html += 'Like ClawFix? Star us on GitHub — it helps a lot.

'; html += '
'; } diff --git a/src/server.js b/src/server.js index 37ce26b..b45bd3d 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,6 @@ import { healthRouter } from './routes/health.js'; import { scriptRouter } from './routes/script.js'; import { installRouter } from './routes/install.js'; import { resultsRouter } from './routes/results.js'; -import { paymentRouter } from './routes/payment.js'; import { webhooksRouter } from './routes/webhooks.js'; import { chatRouter } from './routes/chat.js'; import { agentV2Router } from './routes/agent-v2.js'; @@ -59,8 +58,6 @@ app.use('/api', diagnoseRouter); app.use('/api', healthRouter); app.use('/api', chatRouter); // POST /api/chat — interactive TUI chat app.use('/api', agentV2Router); // POST /api/v2/agent/messages — constrained agent protocol -app.use('/api', paymentRouter); // POST /api/checkout, /api/webhook/lemonsqueezy -app.use('/', paymentRouter); // GET /pay/:fixId — payment page app.use('/', webhooksRouter); // POST /webhooks/resend — inbound email app.use('/', scriptRouter); // GET /fix — diagnostic script app.use('/', installRouter); // GET /install — bash installer diff --git a/src/webhook-signatures.js b/src/webhook-signatures.js index 0a7c74d..3ff2f72 100644 --- a/src/webhook-signatures.js +++ b/src/webhook-signatures.js @@ -20,19 +20,6 @@ export function safeEqual(a, b) { return timingSafeEqual(left, right); } -/** - * Lemon Squeezy: `X-Signature` is hex HMAC-SHA256 of the raw body under the webhook secret. - * https://docs.lemonsqueezy.com/help/webhooks#signing-requests - */ -export function verifyLemonSqueezySignature({ secret, rawBody, signature }) { - if (!secret) return { ok: false, reason: 'not_configured' }; - if (!Buffer.isBuffer(rawBody) || rawBody.length === 0) return { ok: false, reason: 'no_raw_body' }; - if (typeof signature !== 'string' || !signature) return { ok: false, reason: 'missing_signature' }; - - const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); - return safeEqual(signature.trim(), expected) ? { ok: true } : { ok: false, reason: 'bad_signature' }; -} - /** * Svix (used by Resend): `svix-signature` is a space-separated list of `v1,` values, * each an HMAC-SHA256 over `${id}.${timestamp}.${rawBody}` keyed by the base64 secret that diff --git a/test/security-regressions.test.js b/test/security-regressions.test.js index 545bcef..1bd9d83 100644 --- a/test/security-regressions.test.js +++ b/test/security-regressions.test.js @@ -205,18 +205,18 @@ test('HTML routes reject attacker-controlled fix IDs and do not enable cross-ori const { port } = server.address(); const base = `http://127.0.0.1:${port}`; - for (const route of ['/results/%22%3Balert(1)%3B%2F%2F', '/pay/%22%3Balert(1)%3B%2F%2F']) { + for (const route of ['/results/%22%3Balert(1)%3B%2F%2F']) { const response = await fetch(base + route); assert.equal(response.status, 400, route); assert.equal((await response.text()).includes('alert(1)'), false, route); } - const checkout = await fetch(base + '/api/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fixId: '";alert(1);//' }), - }); - assert.equal(checkout.status, 400); + // The payment surface was removed: no checkout, no payment page, no payment webhook. + // These must stay gone rather than quietly reappear. + for (const route of ['/pay/abcdefghij', '/api/checkout', '/api/webhook/lemonsqueezy']) { + const response = await fetch(base + route, { method: route === '/pay/abcdefghij' ? 'GET' : 'POST' }); + assert.equal(response.status, 404, route); + } const crossOrigin = await fetch(base + '/api/stats', { headers: { Origin: 'https://attacker.example' }, diff --git a/test/tooling-regressions.test.js b/test/tooling-regressions.test.js index 6bcfda0..4ff8af3 100644 --- a/test/tooling-regressions.test.js +++ b/test/tooling-regressions.test.js @@ -84,6 +84,11 @@ test('landing page presents truthful evidence for the published 0.11.2 twenty-on // Claims that must stay true of what actually ships. assert.match(landing, /glibc and musl \(Alpine\) hosts/); assert.match(landing, /render, accept typed input, and exit cleanly/); + // Repair scope must stay honest: one executable repair, service-managed hosts only. + assert.match(landing, /One reviewed repair runs automatically today/); + assert.match(landing, /systemd or launchd/); + // The payment surface is gone; the site must not advertise one. + assert.doesNotMatch(landing, /Pay with Card|lemonsqueezy|2 USDC/i); // PR #20 is merged and released; the copy must not still describe it as pending. assert.doesNotMatch(landing, /PR #20 is merged on main/); assert.match(landing, /pull\/20/); diff --git a/test/webhook-security.test.js b/test/webhook-security.test.js index 2102809..02da755 100644 --- a/test/webhook-security.test.js +++ b/test/webhook-security.test.js @@ -2,56 +2,10 @@ import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; import test from 'node:test'; -import { - safeEqual, - verifyLemonSqueezySignature, - verifySvixSignature, -} from '../src/webhook-signatures.js'; +import { safeEqual, verifySvixSignature } from '../src/webhook-signatures.js'; const RAW = Buffer.from('{"meta":{"custom_data":{"fix_id":"abcdefghij"}},"data":{}}', 'utf8'); -// ============================================================ -// Lemon Squeezy -// ============================================================ - -test('lemonsqueezy: a signature over the raw body is accepted', () => { - const secret = 'ls-secret'; - const signature = createHmac('sha256', secret).update(RAW).digest('hex'); - assert.deepEqual(verifyLemonSqueezySignature({ secret, rawBody: RAW, signature }), { ok: true }); -}); - -test('lemonsqueezy: verifying against a re-serialized body fails, which is why raw bytes are kept', () => { - const secret = 'ls-secret'; - // What the route used to hash: JSON.stringify(req.body) after express parsed it. - const reserialized = Buffer.from(JSON.stringify(JSON.parse(RAW.toString())).replace('{"meta"', '{ "meta"'), 'utf8'); - const providerSignature = createHmac('sha256', secret).update(RAW).digest('hex'); - const wouldHaveMatched = createHmac('sha256', secret).update(reserialized).digest('hex'); - - assert.notEqual(providerSignature, wouldHaveMatched); - assert.equal( - verifyLemonSqueezySignature({ secret, rawBody: reserialized, signature: providerSignature }).ok, - false, - ); -}); - -test('lemonsqueezy: an unset secret fails closed instead of skipping verification', () => { - const result = verifyLemonSqueezySignature({ secret: '', rawBody: RAW, signature: 'anything' }); - assert.equal(result.ok, false); - assert.equal(result.reason, 'not_configured'); -}); - -test('lemonsqueezy: a missing raw body is refused rather than assumed valid', () => { - const result = verifyLemonSqueezySignature({ secret: 's', rawBody: undefined, signature: 'x' }); - assert.equal(result.ok, false); - assert.equal(result.reason, 'no_raw_body'); -}); - -test('lemonsqueezy: a forged signature is rejected', () => { - const result = verifyLemonSqueezySignature({ secret: 's', rawBody: RAW, signature: 'f'.repeat(64) }); - assert.equal(result.ok, false); - assert.equal(result.reason, 'bad_signature'); -}); - // ============================================================ // Svix (Resend) // ============================================================