From 0832ffdfea2f52d2b9558d6cd5c0dab8dd959136 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Fri, 6 Nov 2026 18:52:00 +0530 Subject: [PATCH] fix(payments): accept LNbits 1.x callback bodies --- .changeset/lnbits-callback-body.md | 13 +++++ .../callbacks/lnbits-callback-controller.ts | 33 ++++++++---- src/routes/callbacks/index.ts | 28 +++++----- src/schemas/lnbits-callback-schema.ts | 10 ++-- .../lnbits-callback-controller.spec.ts | 53 +++++++++++++++++-- test/unit/routes/callbacks.spec.ts | 39 ++++++++++++-- .../schemas/lnbits-callback-schema.spec.ts | 19 ++++++- 7 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 .changeset/lnbits-callback-body.md diff --git a/.changeset/lnbits-callback-body.md b/.changeset/lnbits-callback-body.md new file mode 100644 index 000000000..7ac252b0a --- /dev/null +++ b/.changeset/lnbits-callback-body.md @@ -0,0 +1,13 @@ +--- +"nostream": patch +--- + +fix(payments): accept LNbits 1.x callback bodies + +LNbits 1.x sends the payment object as a JSON string inside the webhook body. +The callback route now allows a top-level JSON string, the controller parses it +before validation, and the body schema accepts and strips LNbits' extra payment +fields while still requiring a valid `payment_hash`. + +Non-JSON string bodies still return `400 Malformed body`; the HMAC query check +and the invoice lookup from LNbits remain unchanged. diff --git a/src/controllers/callbacks/lnbits-callback-controller.ts b/src/controllers/callbacks/lnbits-callback-controller.ts index 5f0c0db55..e8b93fca8 100644 --- a/src/controllers/callbacks/lnbits-callback-controller.ts +++ b/src/controllers/callbacks/lnbits-callback-controller.ts @@ -1,14 +1,13 @@ import { Request, Response } from 'express' - -import { deriveFromSecret, hmacSha256 } from '../../utils/secret' +import { IController } from '../../@types/controllers' import { Invoice, InvoiceStatus } from '../../@types/invoice' -import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../schemas/lnbits-callback-schema' +import { IInvoiceRepository } from '../../@types/repositories' +import { IPaymentsService } from '../../@types/services' import { createLogger } from '../../factories/logger-factory' import { createSettings } from '../../factories/settings-factory' +import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../schemas/lnbits-callback-schema' import { getRemoteAddress } from '../../utils/http' -import { IController } from '../../@types/controllers' -import { IInvoiceRepository } from '../../@types/repositories' -import { IPaymentsService } from '../../@types/services' +import { deriveFromSecret, hmacSha256 } from '../../utils/secret' import { validateSchema } from '../../utils/validation' const logger = createLogger('lnbits-callback-controller') @@ -28,7 +27,11 @@ export class LNbitsCallbackController implements IController { const queryValidation = validateSchema(lnbitsCallbackQuerySchema)(request.query) if (queryValidation.error) { - logger('unauthorized request from %s to /callbacks/lnbits: invalid query %o', remoteAddress, queryValidation.error) + logger( + 'unauthorized request from %s to /callbacks/lnbits: invalid query %o', + remoteAddress, + queryValidation.error, + ) response.status(403).send('Forbidden') return } @@ -50,13 +53,25 @@ export class LNbitsCallbackController implements IController { return } - const bodyValidation = validateSchema(lnbitsCallbackBodySchema)(request.body) + let rawBody = request.body + if (typeof rawBody === 'string') { + // LNbits 1.x sends the payment object as a JSON *string* inside a JSON + // body. Accept both that double-encoded form and a plain object. + try { + rawBody = JSON.parse(rawBody) + } catch { + response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Malformed body') + return + } + } + + const bodyValidation = validateSchema(lnbitsCallbackBodySchema)(rawBody) if (bodyValidation.error) { response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Malformed body') return } - const body = request.body + const body = bodyValidation.value const invoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(body.payment_hash) const storedInvoice = await this.invoiceRepository.findById(body.payment_hash) diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index 32d8b3e4a..622cc050c 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -3,26 +3,31 @@ import { json, NextFunction, Request, Response, Router, urlencoded } from 'expre import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory' import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory' import { createOpenNodeCallbackController } from '../../factories/controllers/opennode-callback-controller-factory' -import { createSettings } from '../../factories/settings-factory' import { createZebedeeCallbackController } from '../../factories/controllers/zebedee-callback-controller-factory' +import { createSettings } from '../../factories/settings-factory' import { withController } from '../../handlers/request-handlers/with-controller-request-handler' const router: Router = Router() -const requireProcessor = (name: string) => - (_req: Request, res: Response, next: NextFunction) => { - const settings = createSettings() - if (!settings.payments?.enabled || settings.payments.processor !== name) { - res.status(403).send('Forbidden') - return - } - next() +const requireProcessor = (name: string) => (_req: Request, res: Response, next: NextFunction) => { + const settings = createSettings() + if (!settings.payments?.enabled || settings.payments.processor !== name) { + res.status(403).send('Forbidden') + return } + next() +} router .post('/zebedee', requireProcessor('zebedee'), json(), withController(createZebedeeCallbackController)) - .post('/lnbits', requireProcessor('lnbits'), json(), withController(createLNbitsCallbackController)) - .post('/opennode', requireProcessor('opennode'), urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) + .post('/lnbits', requireProcessor('lnbits'), json({ strict: false }), withController(createLNbitsCallbackController)) + .post( + '/opennode', + requireProcessor('opennode'), + urlencoded({ extended: false }), + json(), + withController(createOpenNodeCallbackController), + ) .post( '/nodeless', requireProcessor('nodeless'), @@ -35,4 +40,3 @@ router ) export default router - diff --git a/src/schemas/lnbits-callback-schema.ts b/src/schemas/lnbits-callback-schema.ts index e92d35087..ebe6aa476 100644 --- a/src/schemas/lnbits-callback-schema.ts +++ b/src/schemas/lnbits-callback-schema.ts @@ -1,5 +1,5 @@ -import { idSchema } from './base-schema' import { z } from 'zod' +import { idSchema } from './base-schema' export const lnbitsCallbackQuerySchema = z .object({ @@ -7,8 +7,6 @@ export const lnbitsCallbackQuerySchema = z }) .strict() -export const lnbitsCallbackBodySchema = z - .object({ - payment_hash: idSchema, - }) - .strict() +export const lnbitsCallbackBodySchema = z.object({ + payment_hash: idSchema, +}) diff --git a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts index 9ac78fef7..62e0d4014 100644 --- a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts @@ -7,11 +7,11 @@ chai.use(sinonChai) chai.use(chaiAsPromised) const { expect } = chai -import * as httpUtils from '../../../../src/utils/http' -import * as settingsFactory from '../../../../src/factories/settings-factory' -import { deriveFromSecret, hmacSha256 } from '../../../../src/utils/secret' import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' import { LNbitsCallbackController } from '../../../../src/controllers/callbacks/lnbits-callback-controller' +import * as settingsFactory from '../../../../src/factories/settings-factory' +import * as httpUtils from '../../../../src/utils/http' +import { deriveFromSecret, hmacSha256 } from '../../../../src/utils/secret' const PAYMENT_HASH = 'a'.repeat(64) const PUBKEY = 'b'.repeat(64) @@ -162,6 +162,53 @@ describe('LNbitsCallbackController', () => { expect(res.setHeader).to.have.been.calledWith('content-type', 'text/plain; charset=utf8') expect(res.send).to.have.been.calledWith('Malformed body') }) + + it('accepts a JSON-string body from LNbits 1.x', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + const body = JSON.stringify({ + checking_id: 'checking-id', + payment_hash: PAYMENT_HASH, + amount: 199000, + status: 'paid', + }) + + await controller.handleRequest(makeReq({ body }), res) + + expect(paymentsService.getInvoiceFromPaymentsProcessor).to.have.been.calledWith(PAYMENT_HASH) + expect(res.status).to.have.been.calledWith(200) + }) + + it('accepts an object body carrying LNbits extra fields', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest( + makeReq({ + body: { + checking_id: 'checking-id', + payment_hash: PAYMENT_HASH, + amount: 199000, + status: 'paid', + }, + }), + res, + ) + + expect(paymentsService.getInvoiceFromPaymentsProcessor).to.have.been.calledWith(PAYMENT_HASH) + expect(res.status).to.have.been.calledWith(200) + }) + + it('returns 400 when the string body is not valid JSON', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq({ body: 'not-json' }), res) + + expect(paymentsService.getInvoiceFromPaymentsProcessor).not.to.have.been.called + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Malformed body') + }) }) describe('invoice state handling', () => { diff --git a/test/unit/routes/callbacks.spec.ts b/test/unit/routes/callbacks.spec.ts index d5195284e..9a002fdf4 100644 --- a/test/unit/routes/callbacks.spec.ts +++ b/test/unit/routes/callbacks.spec.ts @@ -2,12 +2,13 @@ import axios from 'axios' import { expect } from 'chai' import express from 'express' import Sinon from 'sinon' - +import * as lnbitsControllerFactory from '../../../src/factories/controllers/lnbits-callback-controller-factory' import * as openNodeControllerFactory from '../../../src/factories/controllers/opennode-callback-controller-factory' import * as settingsFactory from '../../../src/factories/settings-factory' describe('callbacks router', () => { let createOpenNodeCallbackControllerStub: Sinon.SinonStub + let createLNbitsCallbackControllerStub: Sinon.SinonStub let createSettingsStub: Sinon.SinonStub let receivedBody: unknown let server: any @@ -19,7 +20,16 @@ describe('callbacks router', () => { payments: { enabled: true, processor: 'opennode' }, } as any) - createOpenNodeCallbackControllerStub = Sinon.stub(openNodeControllerFactory, 'createOpenNodeCallbackController').returns({ + createOpenNodeCallbackControllerStub = Sinon.stub( + openNodeControllerFactory, + 'createOpenNodeCallbackController', + ).returns({ + handleRequest: async (request: any, response: any) => { + receivedBody = request.body + response.status(200).send('OK') + }, + } as any) + createLNbitsCallbackControllerStub = Sinon.stub(lnbitsControllerFactory, 'createLNbitsCallbackController').returns({ handleRequest: async (request: any, response: any) => { receivedBody = request.body response.status(200).send('OK') @@ -41,6 +51,7 @@ describe('callbacks router', () => { afterEach(async () => { createOpenNodeCallbackControllerStub.restore() + createLNbitsCallbackControllerStub.restore() createSettingsStub.restore() delete require.cache[require.resolve('../../../src/routes/callbacks')] @@ -84,4 +95,26 @@ describe('callbacks router', () => { status: 'paid', }) }) -}) \ No newline at end of file + + it('parses the double-encoded LNbits 1.x callback body', async () => { + createSettingsStub.returns({ + payments: { enabled: true, processor: 'lnbits' }, + } as any) + + const { port } = server.address() + const payment = JSON.stringify({ + checking_id: 'checking-id', + payment_hash: 'a'.repeat(64), + amount: 199000, + status: 'paid', + }) + const body = JSON.stringify(payment) + const response = await axios.post(`http://127.0.0.1:${port}/lnbits?hmac=1:00`, body, { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }) + + expect(response.status).to.equal(200) + expect(receivedBody).to.equal(payment) + }) +}) diff --git a/test/unit/schemas/lnbits-callback-schema.spec.ts b/test/unit/schemas/lnbits-callback-schema.spec.ts index 8eccc02af..a69281daa 100644 --- a/test/unit/schemas/lnbits-callback-schema.spec.ts +++ b/test/unit/schemas/lnbits-callback-schema.spec.ts @@ -1,5 +1,5 @@ -import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../../src/schemas/lnbits-callback-schema' import { expect } from 'chai' +import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../../src/schemas/lnbits-callback-schema' import { validateSchema } from '../../../src/utils/validation' describe('LNbits Callback Schema', () => { @@ -31,6 +31,23 @@ describe('LNbits Callback Schema', () => { expect(result.error).to.be.undefined }) + it('accepts the full LNbits payment object and strips extra fields', () => { + const body = { + checking_id: 'checking-id', + payment_hash: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5', + wallet_id: 'wallet-id', + amount: 199000, + fee: 1000, + bolt11: 'lnbc...', + status: 'paid', + time: 1700000000, + extra: { any: 'value' }, + } + const result = validateSchema(lnbitsCallbackBodySchema)(body) + expect(result.error).to.be.undefined + expect(result.value).to.deep.equal({ payment_hash: body.payment_hash }) + }) + it('returns error if payment_hash is not 64 chars hex', () => { const result = validateSchema(lnbitsCallbackBodySchema)({ payment_hash: 'abc' }) expect(result.error).to.exist