Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/lnbits-callback-body.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 24 additions & 9 deletions src/controllers/callbacks/lnbits-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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
}
Expand All @@ -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)

Expand Down
28 changes: 16 additions & 12 deletions src/routes/callbacks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -35,4 +40,3 @@ router
)

export default router

10 changes: 4 additions & 6 deletions src/schemas/lnbits-callback-schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { idSchema } from './base-schema'
import { z } from 'zod'
import { idSchema } from './base-schema'

export const lnbitsCallbackQuerySchema = z
.object({
hmac: z.string().regex(/^[0-9]{1,20}:[0-9a-f]{64}$/),
})
.strict()

export const lnbitsCallbackBodySchema = z
.object({
payment_hash: idSchema,
})
.strict()
export const lnbitsCallbackBodySchema = z.object({
payment_hash: idSchema,
})
53 changes: 50 additions & 3 deletions test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand Down
39 changes: 36 additions & 3 deletions test/unit/routes/callbacks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')
Expand All @@ -41,6 +51,7 @@ describe('callbacks router', () => {

afterEach(async () => {
createOpenNodeCallbackControllerStub.restore()
createLNbitsCallbackControllerStub.restore()
createSettingsStub.restore()
delete require.cache[require.resolve('../../../src/routes/callbacks')]

Expand Down Expand Up @@ -84,4 +95,26 @@ describe('callbacks router', () => {
status: 'paid',
})
})
})

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)
})
})
19 changes: 18 additions & 1 deletion test/unit/schemas/lnbits-callback-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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
Expand Down
Loading