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
5 changes: 5 additions & 0 deletions .changeset/huge-trains-nail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": patch
---

Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET
36 changes: 24 additions & 12 deletions src/controllers/callbacks/nodeless-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { timingSafeEqual } from 'crypto'

import { always, applySpec, ifElse, is, path, prop, propEq, propSatisfies } from 'ramda'
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')
Expand All @@ -30,20 +31,31 @@ export class NodelessCallbackController implements IController {
return
}

const settings = createSettings()
const paymentProcessor = settings.payments?.processor

const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex')
const actual = request.headers['nodeless-signature']
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
}

if (expected !== actual) {
logger.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
response.status(403).send('Forbidden')
const signatureValidation = validateSchema(nodelessSignatureSchema)(request.headers['nodeless-signature'])
if (signatureValidation.error) {
logger('nodeless callback request rejected: invalid signature format')
response
.status(400)
.setHeader('content-type', 'application/json; charset=utf8')
.send('{"status":"error","message":"Invalid signature"}')
return
}

if (paymentProcessor !== 'nodeless') {
logger('denied request from %s to /callbacks/nodeless which is not the current payment processor')
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
}
Expand Down
11 changes: 9 additions & 2 deletions src/routes/callbacks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -20,6 +27,6 @@ router
}),
withController(createNodelessCallbackController),
)
.post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController))
}

export default router
4 changes: 4 additions & 0 deletions src/schemas/nodeless-callback-schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -84,15 +79,13 @@ const makeReq = (overrides: any = {}): any => {
}

describe('NodelessCallbackController', () => {
let createSettingsStub: sinon.SinonStub
let consoleErrorStub: sinon.SinonStub
let previousWebhookSecret: string | undefined

beforeEach(() => {
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')
})

Expand All @@ -103,7 +96,6 @@ describe('NodelessCallbackController', () => {
process.env.NODELESS_WEBHOOK_SECRET = previousWebhookSecret
}

createSettingsStub.restore()
consoleErrorStub.restore()
})

Expand All @@ -119,28 +111,58 @@ 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 nodeless is not the configured processor', async () => {
createSettingsStub.returns({ payments: { processor: 'zebedee' } })
it('returns 400 when callback signature has wrong length', async () => {
const { controller, paymentsService } = makeController()
const res = makeRes()

await controller.handleRequest(makeReq(), res)
await controller.handleRequest(makeReq({ signature: '0'.repeat(63) }), res)

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 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
})

})

describe('invoice state handling', () => {
Expand Down
Loading