From 341ddea8cc713a1587392176eca55308ddf25e6b Mon Sep 17 00:00:00 2001 From: Anshuman Singh Date: Sat, 11 Apr 2026 05:40:30 +0530 Subject: [PATCH 1/6] fix: parse form-encoded body on OpenNode callback route --- src/routes/callbacks/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index 5b86e0bf..cb23cd64 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -1,4 +1,4 @@ -import { json, Router } from 'express' +import { json, Router, urlencoded } from 'express' import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory' import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory' @@ -16,6 +16,6 @@ router (req as any).rawBody = buf }, }), withController(createNodelessCallbackController)) - .post('/opennode', json(), withController(createOpenNodeCallbackController)) + .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) export default router From 6473ae561275558842c38ac2e4085dc1f9f2eb88 Mon Sep 17 00:00:00 2001 From: Anshuman Singh Date: Sat, 11 Apr 2026 05:40:37 +0530 Subject: [PATCH 2/6] fix: validate OpenNode webhook signature before processing --- .../callbacks/opennode-callback-controller.ts | 89 ++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index f3755df0..32467c69 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -1,8 +1,12 @@ +import { timingSafeEqual } from 'crypto' + import { Request, Response } from 'express' import { Invoice, InvoiceStatus } from '../../@types/invoice' import { createLogger } from '../../factories/logger-factory' -import { fromOpenNodeInvoice } from '../../utils/transform' +import { createSettings } from '../../factories/settings-factory' +import { getRemoteAddress } from '../../utils/http' +import { hmacSha256 } from '../../utils/secret' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' @@ -13,7 +17,6 @@ export class OpenNodeCallbackController implements IController { private readonly paymentsService: IPaymentsService, ) {} - // TODO: Validate public async handleRequest( request: Request, response: Response, @@ -21,7 +24,72 @@ export class OpenNodeCallbackController implements IController { debug('request headers: %o', request.headers) debug('request body: %O', request.body) - const invoice = fromOpenNodeInvoice(request.body) + const settings = createSettings() + const remoteAddress = getRemoteAddress(request, settings) + const paymentProcessor = settings.payments?.processor + + if (paymentProcessor !== 'opennode') { + debug('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress) + response + .status(403) + .send('Forbidden') + return + } + + const validStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid'] + + if ( + !request.body + || typeof request.body.id !== 'string' + || typeof request.body.hashed_order !== 'string' + || typeof request.body.status !== 'string' + || !validStatuses.includes(request.body.status) + ) { + response + .status(400) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Bad Request') + return + } + + const openNodeApiKey = process.env.OPENNODE_API_KEY + if (!openNodeApiKey) { + debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress) + response + .status(500) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Internal Server Error') + return + } + + const expectedBuf = hmacSha256(openNodeApiKey, request.body.id) + const actualHex = request.body.hashed_order + const actualBuf = Buffer.from(actualHex, 'hex') + + if ( + expectedBuf.length !== actualBuf.length + || !timingSafeEqual(expectedBuf, actualBuf) + ) { + debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress) + response + .status(403) + .send('Forbidden') + return + } + + const statusMap: Record = { + expired: InvoiceStatus.EXPIRED, + refunded: InvoiceStatus.EXPIRED, + unpaid: InvoiceStatus.PENDING, + processing: InvoiceStatus.PENDING, + underpaid: InvoiceStatus.PENDING, + paid: InvoiceStatus.COMPLETED, + } + + const invoice: Pick = { + id: request.body.id, + status: statusMap[request.body.status], + } debug('invoice', invoice) @@ -34,10 +102,7 @@ export class OpenNodeCallbackController implements IController { throw error } - if ( - updatedInvoice.status !== InvoiceStatus.COMPLETED - && !updatedInvoice.confirmedAt - ) { + if (updatedInvoice.status !== InvoiceStatus.COMPLETED) { response .status(200) .send() @@ -45,13 +110,15 @@ export class OpenNodeCallbackController implements IController { return } - invoice.amountPaid = invoice.amountRequested - updatedInvoice.amountPaid = invoice.amountRequested + if (!updatedInvoice.confirmedAt) { + updatedInvoice.confirmedAt = new Date() + } + updatedInvoice.amountPaid = updatedInvoice.amountRequested try { await this.paymentsService.confirmInvoice({ - id: invoice.id, - pubkey: invoice.pubkey, + id: updatedInvoice.id, + pubkey: updatedInvoice.pubkey, status: updatedInvoice.status, amountPaid: updatedInvoice.amountRequested, confirmedAt: updatedInvoice.confirmedAt, From 5cc207cab6661ac728a51810bc827b2665d76919 Mon Sep 17 00:00:00 2001 From: Anshuman Singh Date: Sat, 11 Apr 2026 05:40:44 +0530 Subject: [PATCH 3/6] test: add unit tests for OpenNode callback controller and route --- .../opennode-callback-controller.spec.ts | 187 ++++++++++++++++++ test/unit/routes/callbacks.spec.ts | 80 ++++++++ 2 files changed, 267 insertions(+) create mode 100644 test/unit/controllers/callbacks/opennode-callback-controller.spec.ts create mode 100644 test/unit/routes/callbacks.spec.ts diff --git a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts new file mode 100644 index 00000000..c20b5712 --- /dev/null +++ b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts @@ -0,0 +1,187 @@ +import chai, { expect } from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import * as httpUtils from '../../../../src/utils/http' +import * as settingsFactory from '../../../../src/factories/settings-factory' + +import { hmacSha256 } from '../../../../src/utils/secret' +import { InvoiceStatus } from '../../../../src/@types/invoice' +import { OpenNodeCallbackController } from '../../../../src/controllers/callbacks/opennode-callback-controller' + +chai.use(sinonChai) + +describe('OpenNodeCallbackController', () => { + let createSettingsStub: Sinon.SinonStub + let getRemoteAddressStub: Sinon.SinonStub + let updateInvoiceStatusStub: Sinon.SinonStub + let confirmInvoiceStub: Sinon.SinonStub + let sendInvoiceUpdateNotificationStub: Sinon.SinonStub + let statusStub: Sinon.SinonStub + let setHeaderStub: Sinon.SinonStub + let sendStub: Sinon.SinonStub + let controller: OpenNodeCallbackController + let request: any + let response: any + let previousOpenNodeApiKey: string | undefined + + beforeEach(() => { + previousOpenNodeApiKey = process.env.OPENNODE_API_KEY + process.env.OPENNODE_API_KEY = 'test-api-key' + + createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns({ + payments: { processor: 'opennode' }, + } as any) + getRemoteAddressStub = Sinon.stub(httpUtils, 'getRemoteAddress').returns('127.0.0.1') + + updateInvoiceStatusStub = Sinon.stub() + confirmInvoiceStub = Sinon.stub() + sendInvoiceUpdateNotificationStub = Sinon.stub() + + controller = new OpenNodeCallbackController({ + updateInvoiceStatus: updateInvoiceStatusStub, + confirmInvoice: confirmInvoiceStub, + sendInvoiceUpdateNotification: sendInvoiceUpdateNotificationStub, + } as any) + + statusStub = Sinon.stub() + setHeaderStub = Sinon.stub() + sendStub = Sinon.stub() + + response = { + send: sendStub, + setHeader: setHeaderStub, + status: statusStub, + } + + statusStub.returns(response) + setHeaderStub.returns(response) + sendStub.returns(response) + + request = { + body: {}, + headers: {}, + } + }) + + afterEach(() => { + getRemoteAddressStub.restore() + createSettingsStub.restore() + + if (typeof previousOpenNodeApiKey === 'undefined') { + delete process.env.OPENNODE_API_KEY + } else { + process.env.OPENNODE_API_KEY = previousOpenNodeApiKey + } + }) + + it('rejects requests when OpenNode is not the configured payment processor', async () => { + createSettingsStub.returns({ + payments: { processor: 'lnbits' }, + } as any) + + await controller.handleRequest(request, response) + + expect(statusStub).to.have.been.calledOnceWithExactly(403) + expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden') + expect(updateInvoiceStatusStub).not.to.have.been.called + }) + + it('returns bad request for malformed callback bodies', async () => { + request.body = { + id: 'invoice-id', + } + + await controller.handleRequest(request, response) + + expect(statusStub).to.have.been.calledOnceWithExactly(400) + expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8') + expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request') + expect(updateInvoiceStatusStub).not.to.have.been.called + }) + + it('returns bad request for unknown status values', async () => { + request.body = { + hashed_order: 'some-hash', + id: 'invoice-id', + status: 'totally_made_up', + } + + await controller.handleRequest(request, response) + + expect(statusStub).to.have.been.calledOnceWithExactly(400) + expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request') + expect(updateInvoiceStatusStub).not.to.have.been.called + }) + + it('rejects callbacks with mismatched hashed_order', async () => { + request.body = { + hashed_order: 'invalid', + id: 'invoice-id', + status: 'paid', + } + + await controller.handleRequest(request, response) + + expect(statusStub).to.have.been.calledOnceWithExactly(403) + expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden') + expect(updateInvoiceStatusStub).not.to.have.been.called + }) + + it('accepts valid signed callbacks and processes the invoice update', async () => { + request.body = { + amount: 21, + created_at: '2026-04-11T00:00:00.000Z', + description: 'Admission fee', + hashed_order: hmacSha256('test-api-key', 'invoice-id').toString('hex'), + id: 'invoice-id', + lightning: { + expires_at: '2026-04-11T01:00:00.000Z', + payreq: 'lnbc1test', + }, + order_id: 'pubkey', + status: 'unpaid', + } + + updateInvoiceStatusStub.resolves({ + confirmedAt: null, + status: InvoiceStatus.PENDING, + }) + + await controller.handleRequest(request, response) + + expect(updateInvoiceStatusStub).to.have.been.calledOnce + expect(confirmInvoiceStub).not.to.have.been.called + expect(sendInvoiceUpdateNotificationStub).not.to.have.been.called + expect(statusStub).to.have.been.calledOnceWithExactly(200) + expect(sendStub).to.have.been.calledOnceWithExactly() + }) + + it('confirms and notifies on paid callbacks, setting confirmedAt when absent', async () => { + request.body = { + hashed_order: hmacSha256('test-api-key', 'invoice-id').toString('hex'), + id: 'invoice-id', + status: 'paid', + } + + updateInvoiceStatusStub.resolves({ + amountRequested: 1000n, + confirmedAt: null, + id: 'invoice-id', + pubkey: 'somepubkey', + status: InvoiceStatus.COMPLETED, + }) + confirmInvoiceStub.resolves() + sendInvoiceUpdateNotificationStub.resolves() + + await controller.handleRequest(request, response) + + expect(updateInvoiceStatusStub).to.have.been.calledOnce + expect(confirmInvoiceStub).to.have.been.calledOnce + const confirmedAtArg = confirmInvoiceStub.firstCall.args[0].confirmedAt + expect(confirmedAtArg).to.be.instanceOf(Date) + expect(sendInvoiceUpdateNotificationStub).to.have.been.calledOnce + expect(statusStub).to.have.been.calledOnceWithExactly(200) + expect(sendStub).to.have.been.calledOnceWithExactly('OK') + }) +}) \ No newline at end of file diff --git a/test/unit/routes/callbacks.spec.ts b/test/unit/routes/callbacks.spec.ts new file mode 100644 index 00000000..4d5e867c --- /dev/null +++ b/test/unit/routes/callbacks.spec.ts @@ -0,0 +1,80 @@ +import axios from 'axios' +import { expect } from 'chai' +import express from 'express' +import Sinon from 'sinon' + +import * as openNodeControllerFactory from '../../../src/factories/controllers/opennode-callback-controller-factory' + +describe('callbacks router', () => { + let createOpenNodeCallbackControllerStub: Sinon.SinonStub + let receivedBody: unknown + let server: any + + beforeEach(async () => { + receivedBody = undefined + + createOpenNodeCallbackControllerStub = Sinon.stub(openNodeControllerFactory, 'createOpenNodeCallbackController').returns({ + handleRequest: async (request: any, response: any) => { + receivedBody = request.body + response.status(200).send('OK') + }, + } as any) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/callbacks')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + const router = require('../../../src/routes/callbacks').default + + const app = express() + app.use(router) + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)) + }) + }) + + afterEach(async () => { + createOpenNodeCallbackControllerStub.restore() + delete require.cache[require.resolve('../../../src/routes/callbacks')] + + if (server) { + await new Promise((resolve, reject) => { + server.close((error: Error | undefined) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + } + }) + + it('parses form-urlencoded OpenNode callbacks', async () => { + const { port } = server.address() + const response = await axios.post( + `http://127.0.0.1:${port}/opennode`, + new URLSearchParams({ + hashed_order: 'signature', + id: 'invoice-id', + order_id: 'pubkey', + status: 'paid', + }).toString(), + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + validateStatus: () => true, + }, + ) + + expect(response.status).to.equal(200) + expect(receivedBody).to.deep.equal({ + hashed_order: 'signature', + id: 'invoice-id', + order_id: 'pubkey', + status: 'paid', + }) + }) +}) \ No newline at end of file From 42ae225ecdbaef97963cb423524644bde5f0c1c7 Mon Sep 17 00:00:00 2001 From: Anshuman Singh Date: Sat, 11 Apr 2026 17:01:13 +0530 Subject: [PATCH 4/6] fix: align amountPaid and test missing OPENNODE_API_KEY --- .../callbacks/opennode-callback-controller.ts | 2 +- .../opennode-callback-controller.spec.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index 32467c69..732e244a 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -120,7 +120,7 @@ export class OpenNodeCallbackController implements IController { id: updatedInvoice.id, pubkey: updatedInvoice.pubkey, status: updatedInvoice.status, - amountPaid: updatedInvoice.amountRequested, + amountPaid: updatedInvoice.amountPaid, confirmedAt: updatedInvoice.confirmedAt, }) await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice) diff --git a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts index c20b5712..17acc743 100644 --- a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts @@ -114,6 +114,22 @@ describe('OpenNodeCallbackController', () => { expect(updateInvoiceStatusStub).not.to.have.been.called }) + it('returns internal server error when OPENNODE_API_KEY is missing', async () => { + delete process.env.OPENNODE_API_KEY + request.body = { + hashed_order: 'some-hash', + id: 'invoice-id', + status: 'paid', + } + + await controller.handleRequest(request, response) + + expect(statusStub).to.have.been.calledOnceWithExactly(500) + expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8') + expect(sendStub).to.have.been.calledOnceWithExactly('Internal Server Error') + expect(updateInvoiceStatusStub).not.to.have.been.called + }) + it('rejects callbacks with mismatched hashed_order', async () => { request.body = { hashed_order: 'invalid', From e99c0554591537532a58b70fc52e1ac4587675d1 Mon Sep 17 00:00:00 2001 From: Anshuman Singh Date: Sat, 11 Apr 2026 17:08:52 +0530 Subject: [PATCH 5/6] fix: reject malformed OpenNode hashed_order --- .../callbacks/opennode-callback-controller.ts | 17 +++++++++++++++-- .../opennode-callback-controller.spec.ts | 17 ++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index 732e244a..a0375d0a 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -64,11 +64,24 @@ export class OpenNodeCallbackController implements IController { const expectedBuf = hmacSha256(openNodeApiKey, request.body.id) const actualHex = request.body.hashed_order + const expectedHexLength = expectedBuf.length * 2 + + if ( + actualHex.length !== expectedHexLength + || !/^[0-9a-f]+$/i.test(actualHex) + ) { + debug('invalid hashed_order format from %s to /callbacks/opennode', remoteAddress) + response + .status(400) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Bad Request') + return + } + const actualBuf = Buffer.from(actualHex, 'hex') if ( - expectedBuf.length !== actualBuf.length - || !timingSafeEqual(expectedBuf, actualBuf) + !timingSafeEqual(expectedBuf, actualBuf) ) { debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress) response diff --git a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts index 17acc743..0c59eb48 100644 --- a/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/opennode-callback-controller.spec.ts @@ -130,7 +130,7 @@ describe('OpenNodeCallbackController', () => { expect(updateInvoiceStatusStub).not.to.have.been.called }) - it('rejects callbacks with mismatched hashed_order', async () => { + it('returns bad request for malformed hashed_order', async () => { request.body = { hashed_order: 'invalid', id: 'invoice-id', @@ -139,6 +139,21 @@ describe('OpenNodeCallbackController', () => { await controller.handleRequest(request, response) + expect(statusStub).to.have.been.calledOnceWithExactly(400) + expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8') + expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request') + expect(updateInvoiceStatusStub).not.to.have.been.called + }) + + it('rejects callbacks with mismatched hashed_order', async () => { + request.body = { + hashed_order: '0'.repeat(64), + id: 'invoice-id', + status: 'paid', + } + + await controller.handleRequest(request, response) + expect(statusStub).to.have.been.calledOnceWithExactly(403) expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden') expect(updateInvoiceStatusStub).not.to.have.been.called From 949b78ab1a6c30088e5229c9326313cc089cabe6 Mon Sep 17 00:00:00 2001 From: Anshuman Singh Date: Sat, 11 Apr 2026 17:44:52 +0530 Subject: [PATCH 6/6] fix: avoid logging sensitive OpenNode callback fields --- src/controllers/callbacks/opennode-callback-controller.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index a0375d0a..4c3d5fd4 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -22,7 +22,12 @@ export class OpenNodeCallbackController implements IController { response: Response, ) { debug('request headers: %o', request.headers) - debug('request body: %O', request.body) + debug( + 'request body metadata: hasId=%s hasHashedOrder=%s status=%s', + typeof request.body?.id === 'string', + typeof request.body?.hashed_order === 'string', + typeof request.body?.status === 'string' ? request.body.status : 'missing', + ) const settings = createSettings() const remoteAddress = getRemoteAddress(request, settings)