From 3236ad42f2f4c714666554c796ef5f8c6615cd77 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 30 Oct 2026 12:50:00 +0200 Subject: [PATCH] fix: verify LNURL webhooks at the receiver --- integration/002_lnurl.jmx | 65 ++++++++++++++++--- integration/fragments/init-server.jmx | 3 +- .../lnurl_start_webhook_capture.groovy | 27 ++++++++ .../scripts/lnurl_wait_for_webhook.groovy | 28 ++++++++ .../scripts/lnurlp_check_webhook_request.js | 33 ++++++++++ 5 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 integration/scripts/lnurl_start_webhook_capture.groovy create mode 100644 integration/scripts/lnurl_wait_for_webhook.groovy create mode 100644 integration/scripts/lnurlp_check_webhook_request.js diff --git a/integration/002_lnurl.jmx b/integration/002_lnurl.jmx index 0f87247d..f439b253 100644 --- a/integration/002_lnurl.jmx +++ b/integration/002_lnurl.jmx @@ -68,6 +68,11 @@ ${__property(mirrorPort,,8500)} = + + webhookCapturePort + ${__property(webhookCapturePort,,8501)} + = + configPath ${__BeanShell(import org.apache.jmeter.services.FileServer; FileServer.getFileServer().getBaseDir();)} @@ -106,6 +111,14 @@ false + + groovy + + ${configPath}/scripts/lnurl_start_webhook_capture.groovy + true + + + true false @@ -406,7 +419,7 @@ false - {"description":"receive payments ${lnurlpCounter}","min":11,"comment_chars":128,"webhook_url":"http://localhost:${mirrorPort}","webhook_headers":"{\"h1\": \"1\"}","webhook_body":"{\"b2\": 2}","success_text":"All goood!","success_url":"https://lnbits.com","max":${lnurlpCounter}1} + {"description":"receive payments ${lnurlpCounter}","min":11,"comment_chars":128,"webhook_url":"http://127.0.0.1:${webhookCapturePort}/","webhook_headers":"{\"h1\": \"1\"}","webhook_body":"{\"b2\": 2}","success_text":"All goood!","success_url":"https://lnbits.com","max":${lnurlpCounter}1} = @@ -972,11 +985,6 @@ - - 100 - for the webhook to be called - - false @@ -1019,6 +1027,14 @@ 8 + + groovy + + ${configPath}/scripts/lnurl_wait_for_webhook.groovy + true + + + $.details.extra.wh_success true @@ -1030,17 +1046,17 @@ $.details.extra.wh_message - OK + Webhook delivered. true false false false - + javascript - ${configPath}/scripts/lnurl_check_webhook_response.js + ${configPath}/scripts/lnurlp_check_webhook_request.js true @@ -1213,7 +1229,7 @@ Withdraw links - curl -X GET http://127.0.0.1:5000/withdraw/api/v1/links -H + curl -X GET ${scheme}://${host}:${port}/withdraw/api/v1/links -H Assertion.response_data @@ -2062,6 +2078,14 @@ 8 + + groovy + + ${configPath}/scripts/lnurl_wait_for_webhook.groovy + true + + + $.details.extra.wh_success true @@ -2485,6 +2509,27 @@ + + continue + + false + 1 + + 1 + 1 + false + + + + groovy + + + true + props.remove('lnurlWebhookServer')?.stop(0) +props.remove('lnurlWebhookRequests') + + + diff --git a/integration/fragments/init-server.jmx b/integration/fragments/init-server.jmx index 547eba52..23a941eb 100644 --- a/integration/fragments/init-server.jmx +++ b/integration/fragments/init-server.jmx @@ -369,7 +369,8 @@ false { "lnbits_callback_url_rules": [], - "lnbits_lnurl_allow_private_ips": true + "lnbits_lnurl_allow_private_ips": true, + "lnbits_callback_allow_private_ips": true } = diff --git a/integration/scripts/lnurl_start_webhook_capture.groovy b/integration/scripts/lnurl_start_webhook_capture.groovy new file mode 100644 index 00000000..f3b6df49 --- /dev/null +++ b/integration/scripts/lnurl_start_webhook_capture.groovy @@ -0,0 +1,27 @@ +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.util.concurrent.ConcurrentHashMap + +def requests = new ConcurrentHashMap() +def server = HttpServer.create( + new InetSocketAddress('127.0.0.1', vars.get('webhookCapturePort').toInteger()), 0) +server.createContext('/', { exchange -> + try { + def body = new JsonSlurper().parseText(exchange.requestBody.getText('UTF-8')) + requests.put(body.payment_hash, JsonOutput.toJson([ + method: exchange.requestMethod, + header: exchange.requestHeaders.getFirst('h1'), + body: body + ])) + byte[] response = 'Webhook response must not appear in payment metadata'.getBytes('UTF-8') + exchange.sendResponseHeaders(200, response.length) + exchange.responseBody.write(response) + } finally { + exchange.close() + } +} as HttpHandler) +server.start() +props.put('lnurlWebhookRequests', requests) +props.put('lnurlWebhookServer', server) diff --git a/integration/scripts/lnurl_wait_for_webhook.groovy b/integration/scripts/lnurl_wait_for_webhook.groovy new file mode 100644 index 00000000..8ab82b3b --- /dev/null +++ b/integration/scripts/lnurl_wait_for_webhook.groovy @@ -0,0 +1,28 @@ +import groovy.json.JsonSlurper +import java.util.concurrent.TimeUnit + +def deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) +def poll = sampler.clone() +poll.setConnectTimeout('2000') +poll.setResponseTimeout('2000') +def response = prev + +// Stop on a completed delivery (including failure) or an HTTP error. +while (response.successful && System.nanoTime() < deadline) { + def payment = new JsonSlurper().parseText(response.responseDataAsString) + if (payment.details?.extra?.containsKey('wh_success')) { + break + } + Thread.sleep(250) + response = poll.sample() + prev.addSubResult(response) +} + +// The existing assertions must inspect the final response, including failures. +if (!response.is(prev)) { + prev.setResponseData(response.responseData) + prev.setResponseCode(response.responseCode) + prev.setResponseMessage(response.responseMessage) + prev.setSuccessful(response.successful) + prev.setEndTime(System.currentTimeMillis()) +} diff --git a/integration/scripts/lnurlp_check_webhook_request.js b/integration/scripts/lnurlp_check_webhook_request.js new file mode 100644 index 00000000..0a50690f --- /dev/null +++ b/integration/scripts/lnurlp_check_webhook_request.js @@ -0,0 +1,33 @@ +var payment = JSON.parse(prev.getResponseDataAsString()); +var paymentHash = vars.get("paymenHash"); +var captured = props.get("lnurlWebhookRequests").remove(paymentHash); +var extra = payment.details.extra; + +if (extra.wh_status !== 200 || extra.wh_response !== "") { + AssertionResult.setFailureMessage( + "Expected HTTP 200 delivery with a redacted webhook response", + ); + AssertionResult.setFailure(true); +} else if (captured === null) { + AssertionResult.setFailureMessage( + "No webhook received for this payment hash", + ); + AssertionResult.setFailure(true); +} else { + var request = JSON.parse(String(captured)); + if ( + request.method !== "POST" || + request.body.payment_hash !== paymentHash + ) { + AssertionResult.setFailureMessage( + "Incorrect webhook method or payment hash", + ); + AssertionResult.setFailure(true); + } else if (request.header !== "1") { + AssertionResult.setFailureMessage("Expected webhook header h1: 1"); + AssertionResult.setFailure(true); + } else if (!request.body.body || request.body.body.b2 !== 2) { + AssertionResult.setFailureMessage("Expected webhook body field b2: 2"); + AssertionResult.setFailure(true); + } +}