Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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')
Comment thread
cameri marked this conversation as resolved.
Outdated
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)
Comment thread
cameri marked this conversation as resolved.
Outdated
) {
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