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
39 changes: 31 additions & 8 deletions src/controllers/callbacks/nodeless-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { timingSafeEqual } from 'crypto'

import { always, applySpec, ifElse, is, path, prop, propEq, propSatisfies } from 'ramda'
import { Request, Response } from 'express'

Expand All @@ -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)
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading