From eb1ba77d28ae18a3ff5f2a8f4867ae7b0d833b19 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 26 Apr 2026 22:20:19 +0530 Subject: [PATCH 1/4] fix: use timingSafeEqual for nodeless webhook HMAC verification --- .../callbacks/nodeless-callback-controller.ts | 39 ++++++++++++++---- .../nodeless-callback-controller.spec.ts | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index 72f01e4c..3d7ff073 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -1,3 +1,5 @@ +import { timingSafeEqual } from 'crypto' + import { always, applySpec, ifElse, is, path, prop, propEq, propSatisfies } from 'ramda' import { Request, Response } from 'express' @@ -20,6 +22,15 @@ export class NodelessCallbackController implements IController { logger('callback request headers: %o', request.headers) logger('callback request body: %O', request.body) + const settings = createSettings() + const paymentProcessor = settings.payments?.processor + + if (paymentProcessor !== 'nodeless') { + logger('denied request to /callbacks/nodeless which is not the current payment processor') + response.status(403).send('Forbidden') + return + } + const bodyValidation = validateSchema(nodelessCallbackBodySchema)(request.body) if (bodyValidation.error) { logger('nodeless callback request rejected: invalid body %o', bodyValidation.error) @@ -30,20 +41,32 @@ export class NodelessCallbackController implements IController { return } - const settings = createSettings() - const paymentProcessor = settings.payments?.processor + const webhookSecret = process.env.NODELESS_WEBHOOK_SECRET + if (!webhookSecret) { + logger.error('NODELESS_WEBHOOK_SECRET is not configured; unable to verify Nodeless callback') + response + .status(500) + .setHeader('content-type', 'application/json; charset=utf8') + .send('{"status":"error","message":"Internal Server Error"}') + return + } - const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex') - const actual = request.headers['nodeless-signature'] + const expectedBuf = hmacSha256(webhookSecret, (request as any).rawBody) + const actualHex = request.headers['nodeless-signature'] + const expectedHexLength = expectedBuf.length * 2 - if (expected !== actual) { - logger.error('nodeless callback request rejected: signature mismatch:', { expected, actual }) + if ( + typeof actualHex !== 'string' || + actualHex.length !== expectedHexLength || + !/^[0-9a-f]+$/i.test(actualHex) + ) { + logger('nodeless callback request rejected: invalid signature format') response.status(403).send('Forbidden') return } - if (paymentProcessor !== 'nodeless') { - logger('denied request from %s to /callbacks/nodeless which is not the current payment processor') + if (!timingSafeEqual(expectedBuf, Buffer.from(actualHex, 'hex'))) { + logger('nodeless callback request rejected: signature mismatch') response.status(403).send('Forbidden') return } diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts index b0c91d3f..baca4fde 100644 --- a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -130,6 +130,47 @@ describe('NodelessCallbackController', () => { expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) + it('returns 403 when callback signature has wrong length', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq({ signature: '0'.repeat(63) }), res) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 403 when callback signature is a valid-length hex string but does not match', async () => { + const { controller, paymentsService } = makeController() + const res = makeRes() + + await controller.handleRequest(makeReq({ signature: '0'.repeat(64) }), res) + + expect(res.status).to.have.been.calledWith(403) + expect(res.send).to.have.been.calledWith('Forbidden') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + + it('returns 500 when NODELESS_WEBHOOK_SECRET is not configured', async () => { + delete process.env.NODELESS_WEBHOOK_SECRET + const { controller, paymentsService } = makeController() + const res = makeRes() + const rawBody = Buffer.from(JSON.stringify(validBody)) + const req = { + headers: { 'nodeless-signature': 'does-not-matter' }, + body: validBody, + rawBody, + } + + await controller.handleRequest(req as any, res) + + expect(res.status).to.have.been.calledWith(500) + expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8') + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Internal Server Error"}') + expect(paymentsService.updateInvoiceStatus).to.not.have.been.called + }) + it('returns 403 when nodeless is not the configured processor', async () => { createSettingsStub.returns({ payments: { processor: 'zebedee' } }) const { controller, paymentsService } = makeController() From c738a286a2e12020247fa530d3c7a32e3c64a672 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 26 Apr 2026 22:34:33 +0530 Subject: [PATCH 2/4] chore: add changeset for nodeless hmac fix --- .changeset/fix-nodeless-hmac-timing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-nodeless-hmac-timing.md diff --git a/.changeset/fix-nodeless-hmac-timing.md b/.changeset/fix-nodeless-hmac-timing.md new file mode 100644 index 00000000..d595bed5 --- /dev/null +++ b/.changeset/fix-nodeless-hmac-timing.md @@ -0,0 +1,5 @@ +--- +"nostream": patch +--- + +Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET. From a6c104adce7cc3c201b7b2942da7a989d237f627 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Sun, 26 Apr 2026 22:36:52 +0530 Subject: [PATCH 3/4] docs(changeset): Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET --- .changeset/{fix-nodeless-hmac-timing.md => huge-trains-nail.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .changeset/{fix-nodeless-hmac-timing.md => huge-trains-nail.md} (66%) diff --git a/.changeset/fix-nodeless-hmac-timing.md b/.changeset/huge-trains-nail.md similarity index 66% rename from .changeset/fix-nodeless-hmac-timing.md rename to .changeset/huge-trains-nail.md index d595bed5..9b093e72 100644 --- a/.changeset/fix-nodeless-hmac-timing.md +++ b/.changeset/huge-trains-nail.md @@ -2,4 +2,4 @@ "nostream": patch --- -Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET. +Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET From c48661a83c1351443707157c0853908bf43072f7 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Wed, 29 Apr 2026 22:47:05 +0530 Subject: [PATCH 4/4] refactor: use zod for signature validation and only register route when enabled --- .../callbacks/nodeless-callback-controller.ts | 33 +++++++------------ src/routes/callbacks/index.ts | 11 +++++-- src/schemas/nodeless-callback-schema.ts | 4 +++ .../nodeless-callback-controller.spec.ts | 31 ++++------------- 4 files changed, 30 insertions(+), 49 deletions(-) diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index 3d7ff073..7aca3a92 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -5,12 +5,11 @@ import { Request, Response } from 'express' import { Invoice, InvoiceStatus } from '../../@types/invoice' import { createLogger } from '../../factories/logger-factory' -import { createSettings } from '../../factories/settings-factory' import { fromNodelessInvoice } from '../../utils/transform' import { hmacSha256 } from '../../utils/secret' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' -import { nodelessCallbackBodySchema } from '../../schemas/nodeless-callback-schema' +import { nodelessCallbackBodySchema, nodelessSignatureSchema } from '../../schemas/nodeless-callback-schema' import { validateSchema } from '../../utils/validation' const logger = createLogger('nodeless-callback-controller') @@ -22,15 +21,6 @@ export class NodelessCallbackController implements IController { logger('callback request headers: %o', request.headers) logger('callback request body: %O', request.body) - const settings = createSettings() - const paymentProcessor = settings.payments?.processor - - if (paymentProcessor !== 'nodeless') { - logger('denied request to /callbacks/nodeless which is not the current payment processor') - response.status(403).send('Forbidden') - return - } - const bodyValidation = validateSchema(nodelessCallbackBodySchema)(request.body) if (bodyValidation.error) { logger('nodeless callback request rejected: invalid body %o', bodyValidation.error) @@ -51,21 +41,20 @@ export class NodelessCallbackController implements IController { return } - const expectedBuf = hmacSha256(webhookSecret, (request as any).rawBody) - const actualHex = request.headers['nodeless-signature'] - const expectedHexLength = expectedBuf.length * 2 - - if ( - typeof actualHex !== 'string' || - actualHex.length !== expectedHexLength || - !/^[0-9a-f]+$/i.test(actualHex) - ) { + const signatureValidation = validateSchema(nodelessSignatureSchema)(request.headers['nodeless-signature']) + if (signatureValidation.error) { logger('nodeless callback request rejected: invalid signature format') - response.status(403).send('Forbidden') + response + .status(400) + .setHeader('content-type', 'application/json; charset=utf8') + .send('{"status":"error","message":"Invalid signature"}') return } - if (!timingSafeEqual(expectedBuf, Buffer.from(actualHex, 'hex'))) { + const expectedBuf = hmacSha256(webhookSecret, (request as any).rawBody) + const actualBuf = Buffer.from(signatureValidation.value, 'hex') + + if (!timingSafeEqual(expectedBuf, actualBuf)) { logger('nodeless callback request rejected: signature mismatch') response.status(403).send('Forbidden') return diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index 944ef970..eed0ea49 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -3,15 +3,22 @@ import { json, Router, urlencoded } from 'express' 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 { withController } from '../../handlers/request-handlers/with-controller-request-handler' const router: Router = Router() +const settings = createSettings() +const processor = settings.payments?.processor + router .post('/zebedee', json(), withController(createZebedeeCallbackController)) .post('/lnbits', json(), withController(createLNbitsCallbackController)) - .post( + .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) + +if (processor === 'nodeless') { + router.post( '/nodeless', json({ verify(req, _res, buf) { @@ -20,6 +27,6 @@ router }), withController(createNodelessCallbackController), ) - .post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController)) +} export default router diff --git a/src/schemas/nodeless-callback-schema.ts b/src/schemas/nodeless-callback-schema.ts index 8413c88d..b89d3b5b 100644 --- a/src/schemas/nodeless-callback-schema.ts +++ b/src/schemas/nodeless-callback-schema.ts @@ -1,6 +1,10 @@ import { pubkeySchema } from './base-schema' import { z } from 'zod' +const hexRegex = /^[0-9a-f]+$/i + +export const nodelessSignatureSchema = z.string().regex(hexRegex).length(64) + export const nodelessCallbackBodySchema = z .object({ id: z.string().optional(), diff --git a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts index baca4fde..341ff75c 100644 --- a/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts @@ -7,17 +7,12 @@ chai.use(sinonChai) chai.use(chaiAsPromised) const { expect } = chai -import * as settingsFactory from '../../../../src/factories/settings-factory' import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' import { hmacSha256 } from '../../../../src/utils/secret' import { NodelessCallbackController } from '../../../../src/controllers/callbacks/nodeless-callback-controller' const PUBKEY = 'a'.repeat(64) -const baseSettings: any = { - payments: { processor: 'nodeless' }, -} - const validBody = { uuid: 'nodeless-invoice-id', status: 'paid', @@ -84,7 +79,6 @@ const makeReq = (overrides: any = {}): any => { } describe('NodelessCallbackController', () => { - let createSettingsStub: sinon.SinonStub let consoleErrorStub: sinon.SinonStub let previousWebhookSecret: string | undefined @@ -92,7 +86,6 @@ describe('NodelessCallbackController', () => { previousWebhookSecret = process.env.NODELESS_WEBHOOK_SECRET process.env.NODELESS_WEBHOOK_SECRET = 'nodeless-test-secret' - createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings) consoleErrorStub = sinon.stub(console, 'error') }) @@ -103,7 +96,6 @@ describe('NodelessCallbackController', () => { process.env.NODELESS_WEBHOOK_SECRET = previousWebhookSecret } - createSettingsStub.restore() consoleErrorStub.restore() }) @@ -119,25 +111,25 @@ describe('NodelessCallbackController', () => { expect(res.send).to.have.been.calledWith('{"status":"error","message":"Malformed body"}') }) - it('returns 403 when callback signature is invalid', async () => { + it('returns 400 when callback signature has invalid format', async () => { const { controller, paymentsService } = makeController() const res = makeRes() await controller.handleRequest(makeReq({ signature: 'invalid-signature' }), res) - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Invalid signature"}') expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) - it('returns 403 when callback signature has wrong length', async () => { + it('returns 400 when callback signature has wrong length', async () => { const { controller, paymentsService } = makeController() const res = makeRes() await controller.handleRequest(makeReq({ signature: '0'.repeat(63) }), res) - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('{"status":"error","message":"Invalid signature"}') expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) @@ -171,17 +163,6 @@ describe('NodelessCallbackController', () => { expect(paymentsService.updateInvoiceStatus).to.not.have.been.called }) - it('returns 403 when nodeless is not the configured processor', async () => { - createSettingsStub.returns({ payments: { processor: 'zebedee' } }) - const { controller, paymentsService } = makeController() - const res = makeRes() - - await controller.handleRequest(makeReq(), res) - - expect(res.status).to.have.been.calledWith(403) - expect(res.send).to.have.been.calledWith('Forbidden') - expect(paymentsService.updateInvoiceStatus).to.not.have.been.called - }) }) describe('invoice state handling', () => {