Skip to content

Commit 3e4a3a3

Browse files
authored
Merge branch 'main' into fix/532-root-browser-html
2 parents 24a881e + a6d32b1 commit 3e4a3a3

5 files changed

Lines changed: 78 additions & 28 deletions

File tree

.changeset/huge-trains-nail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": patch
3+
---
4+
5+
Use timingSafeEqual for Nodeless webhook HMAC verification and guard against missing NODELESS_WEBHOOK_SECRET

src/controllers/callbacks/nodeless-callback-controller.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
import { timingSafeEqual } from 'crypto'
2+
13
import { always, applySpec, ifElse, is, path, prop, propEq, propSatisfies } from 'ramda'
24
import { Request, Response } from 'express'
35

46
import { Invoice, InvoiceStatus } from '../../@types/invoice'
57
import { createLogger } from '../../factories/logger-factory'
6-
import { createSettings } from '../../factories/settings-factory'
78
import { fromNodelessInvoice } from '../../utils/transform'
89
import { hmacSha256 } from '../../utils/secret'
910
import { IController } from '../../@types/controllers'
1011
import { IPaymentsService } from '../../@types/services'
11-
import { nodelessCallbackBodySchema } from '../../schemas/nodeless-callback-schema'
12+
import { nodelessCallbackBodySchema, nodelessSignatureSchema } from '../../schemas/nodeless-callback-schema'
1213
import { validateSchema } from '../../utils/validation'
1314

1415
const logger = createLogger('nodeless-callback-controller')
@@ -30,20 +31,31 @@ export class NodelessCallbackController implements IController {
3031
return
3132
}
3233

33-
const settings = createSettings()
34-
const paymentProcessor = settings.payments?.processor
35-
36-
const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex')
37-
const actual = request.headers['nodeless-signature']
34+
const webhookSecret = process.env.NODELESS_WEBHOOK_SECRET
35+
if (!webhookSecret) {
36+
logger.error('NODELESS_WEBHOOK_SECRET is not configured; unable to verify Nodeless callback')
37+
response
38+
.status(500)
39+
.setHeader('content-type', 'application/json; charset=utf8')
40+
.send('{"status":"error","message":"Internal Server Error"}')
41+
return
42+
}
3843

39-
if (expected !== actual) {
40-
logger.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
41-
response.status(403).send('Forbidden')
44+
const signatureValidation = validateSchema(nodelessSignatureSchema)(request.headers['nodeless-signature'])
45+
if (signatureValidation.error) {
46+
logger('nodeless callback request rejected: invalid signature format')
47+
response
48+
.status(400)
49+
.setHeader('content-type', 'application/json; charset=utf8')
50+
.send('{"status":"error","message":"Invalid signature"}')
4251
return
4352
}
4453

45-
if (paymentProcessor !== 'nodeless') {
46-
logger('denied request from %s to /callbacks/nodeless which is not the current payment processor')
54+
const expectedBuf = hmacSha256(webhookSecret, (request as any).rawBody)
55+
const actualBuf = Buffer.from(signatureValidation.value, 'hex')
56+
57+
if (!timingSafeEqual(expectedBuf, actualBuf)) {
58+
logger('nodeless callback request rejected: signature mismatch')
4759
response.status(403).send('Forbidden')
4860
return
4961
}

src/routes/callbacks/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@ import { json, Router, urlencoded } from 'express'
33
import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory'
44
import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory'
55
import { createOpenNodeCallbackController } from '../../factories/controllers/opennode-callback-controller-factory'
6+
import { createSettings } from '../../factories/settings-factory'
67
import { createZebedeeCallbackController } from '../../factories/controllers/zebedee-callback-controller-factory'
78
import { withController } from '../../handlers/request-handlers/with-controller-request-handler'
89

910
const router: Router = Router()
1011

12+
const settings = createSettings()
13+
const processor = settings.payments?.processor
14+
1115
router
1216
.post('/zebedee', json(), withController(createZebedeeCallbackController))
1317
.post('/lnbits', json(), withController(createLNbitsCallbackController))
14-
.post(
18+
.post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController))
19+
20+
if (processor === 'nodeless') {
21+
router.post(
1522
'/nodeless',
1623
json({
1724
verify(req, _res, buf) {
@@ -20,6 +27,6 @@ router
2027
}),
2128
withController(createNodelessCallbackController),
2229
)
23-
.post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController))
30+
}
2431

2532
export default router

src/schemas/nodeless-callback-schema.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { pubkeySchema } from './base-schema'
22
import { z } from 'zod'
33

4+
const hexRegex = /^[0-9a-f]+$/i
5+
6+
export const nodelessSignatureSchema = z.string().regex(hexRegex).length(64)
7+
48
export const nodelessCallbackBodySchema = z
59
.object({
610
id: z.string().optional(),

test/unit/controllers/callbacks/nodeless-callback-controller.spec.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,12 @@ chai.use(sinonChai)
77
chai.use(chaiAsPromised)
88
const { expect } = chai
99

10-
import * as settingsFactory from '../../../../src/factories/settings-factory'
1110
import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice'
1211
import { hmacSha256 } from '../../../../src/utils/secret'
1312
import { NodelessCallbackController } from '../../../../src/controllers/callbacks/nodeless-callback-controller'
1413

1514
const PUBKEY = 'a'.repeat(64)
1615

17-
const baseSettings: any = {
18-
payments: { processor: 'nodeless' },
19-
}
20-
2116
const validBody = {
2217
uuid: 'nodeless-invoice-id',
2318
status: 'paid',
@@ -84,15 +79,13 @@ const makeReq = (overrides: any = {}): any => {
8479
}
8580

8681
describe('NodelessCallbackController', () => {
87-
let createSettingsStub: sinon.SinonStub
8882
let consoleErrorStub: sinon.SinonStub
8983
let previousWebhookSecret: string | undefined
9084

9185
beforeEach(() => {
9286
previousWebhookSecret = process.env.NODELESS_WEBHOOK_SECRET
9387
process.env.NODELESS_WEBHOOK_SECRET = 'nodeless-test-secret'
9488

95-
createSettingsStub = sinon.stub(settingsFactory, 'createSettings').returns(baseSettings)
9689
consoleErrorStub = sinon.stub(console, 'error')
9790
})
9891

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

106-
createSettingsStub.restore()
10799
consoleErrorStub.restore()
108100
})
109101

@@ -119,28 +111,58 @@ describe('NodelessCallbackController', () => {
119111
expect(res.send).to.have.been.calledWith('{"status":"error","message":"Malformed body"}')
120112
})
121113

122-
it('returns 403 when callback signature is invalid', async () => {
114+
it('returns 400 when callback signature has invalid format', async () => {
123115
const { controller, paymentsService } = makeController()
124116
const res = makeRes()
125117

126118
await controller.handleRequest(makeReq({ signature: 'invalid-signature' }), res)
127119

128-
expect(res.status).to.have.been.calledWith(403)
129-
expect(res.send).to.have.been.calledWith('Forbidden')
120+
expect(res.status).to.have.been.calledWith(400)
121+
expect(res.send).to.have.been.calledWith('{"status":"error","message":"Invalid signature"}')
130122
expect(paymentsService.updateInvoiceStatus).to.not.have.been.called
131123
})
132124

133-
it('returns 403 when nodeless is not the configured processor', async () => {
134-
createSettingsStub.returns({ payments: { processor: 'zebedee' } })
125+
it('returns 400 when callback signature has wrong length', async () => {
135126
const { controller, paymentsService } = makeController()
136127
const res = makeRes()
137128

138-
await controller.handleRequest(makeReq(), res)
129+
await controller.handleRequest(makeReq({ signature: '0'.repeat(63) }), res)
130+
131+
expect(res.status).to.have.been.calledWith(400)
132+
expect(res.send).to.have.been.calledWith('{"status":"error","message":"Invalid signature"}')
133+
expect(paymentsService.updateInvoiceStatus).to.not.have.been.called
134+
})
135+
136+
it('returns 403 when callback signature is a valid-length hex string but does not match', async () => {
137+
const { controller, paymentsService } = makeController()
138+
const res = makeRes()
139+
140+
await controller.handleRequest(makeReq({ signature: '0'.repeat(64) }), res)
139141

140142
expect(res.status).to.have.been.calledWith(403)
141143
expect(res.send).to.have.been.calledWith('Forbidden')
142144
expect(paymentsService.updateInvoiceStatus).to.not.have.been.called
143145
})
146+
147+
it('returns 500 when NODELESS_WEBHOOK_SECRET is not configured', async () => {
148+
delete process.env.NODELESS_WEBHOOK_SECRET
149+
const { controller, paymentsService } = makeController()
150+
const res = makeRes()
151+
const rawBody = Buffer.from(JSON.stringify(validBody))
152+
const req = {
153+
headers: { 'nodeless-signature': 'does-not-matter' },
154+
body: validBody,
155+
rawBody,
156+
}
157+
158+
await controller.handleRequest(req as any, res)
159+
160+
expect(res.status).to.have.been.calledWith(500)
161+
expect(res.setHeader).to.have.been.calledWith('content-type', 'application/json; charset=utf8')
162+
expect(res.send).to.have.been.calledWith('{"status":"error","message":"Internal Server Error"}')
163+
expect(paymentsService.updateInvoiceStatus).to.not.have.been.called
164+
})
165+
144166
})
145167

146168
describe('invoice state handling', () => {

0 commit comments

Comments
 (0)