Skip to content

Commit 6473ae5

Browse files
fix: validate OpenNode webhook signature before processing
1 parent 341ddea commit 6473ae5

1 file changed

Lines changed: 78 additions & 11 deletions

File tree

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

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import { timingSafeEqual } from 'crypto'
2+
13
import { Request, Response } from 'express'
24

35
import { Invoice, InvoiceStatus } from '../../@types/invoice'
46
import { createLogger } from '../../factories/logger-factory'
5-
import { fromOpenNodeInvoice } from '../../utils/transform'
7+
import { createSettings } from '../../factories/settings-factory'
8+
import { getRemoteAddress } from '../../utils/http'
9+
import { hmacSha256 } from '../../utils/secret'
610
import { IController } from '../../@types/controllers'
711
import { IPaymentsService } from '../../@types/services'
812

@@ -13,15 +17,79 @@ export class OpenNodeCallbackController implements IController {
1317
private readonly paymentsService: IPaymentsService,
1418
) {}
1519

16-
// TODO: Validate
1720
public async handleRequest(
1821
request: Request,
1922
response: Response,
2023
) {
2124
debug('request headers: %o', request.headers)
2225
debug('request body: %O', request.body)
2326

24-
const invoice = fromOpenNodeInvoice(request.body)
27+
const settings = createSettings()
28+
const remoteAddress = getRemoteAddress(request, settings)
29+
const paymentProcessor = settings.payments?.processor
30+
31+
if (paymentProcessor !== 'opennode') {
32+
debug('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress)
33+
response
34+
.status(403)
35+
.send('Forbidden')
36+
return
37+
}
38+
39+
const validStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid']
40+
41+
if (
42+
!request.body
43+
|| typeof request.body.id !== 'string'
44+
|| typeof request.body.hashed_order !== 'string'
45+
|| typeof request.body.status !== 'string'
46+
|| !validStatuses.includes(request.body.status)
47+
) {
48+
response
49+
.status(400)
50+
.setHeader('content-type', 'text/plain; charset=utf8')
51+
.send('Bad Request')
52+
return
53+
}
54+
55+
const openNodeApiKey = process.env.OPENNODE_API_KEY
56+
if (!openNodeApiKey) {
57+
debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
58+
response
59+
.status(500)
60+
.setHeader('content-type', 'text/plain; charset=utf8')
61+
.send('Internal Server Error')
62+
return
63+
}
64+
65+
const expectedBuf = hmacSha256(openNodeApiKey, request.body.id)
66+
const actualHex = request.body.hashed_order
67+
const actualBuf = Buffer.from(actualHex, 'hex')
68+
69+
if (
70+
expectedBuf.length !== actualBuf.length
71+
|| !timingSafeEqual(expectedBuf, actualBuf)
72+
) {
73+
debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress)
74+
response
75+
.status(403)
76+
.send('Forbidden')
77+
return
78+
}
79+
80+
const statusMap: Record<string, InvoiceStatus> = {
81+
expired: InvoiceStatus.EXPIRED,
82+
refunded: InvoiceStatus.EXPIRED,
83+
unpaid: InvoiceStatus.PENDING,
84+
processing: InvoiceStatus.PENDING,
85+
underpaid: InvoiceStatus.PENDING,
86+
paid: InvoiceStatus.COMPLETED,
87+
}
88+
89+
const invoice: Pick<Invoice, 'id' | 'status'> = {
90+
id: request.body.id,
91+
status: statusMap[request.body.status],
92+
}
2593

2694
debug('invoice', invoice)
2795

@@ -34,24 +102,23 @@ export class OpenNodeCallbackController implements IController {
34102
throw error
35103
}
36104

37-
if (
38-
updatedInvoice.status !== InvoiceStatus.COMPLETED
39-
&& !updatedInvoice.confirmedAt
40-
) {
105+
if (updatedInvoice.status !== InvoiceStatus.COMPLETED) {
41106
response
42107
.status(200)
43108
.send()
44109

45110
return
46111
}
47112

48-
invoice.amountPaid = invoice.amountRequested
49-
updatedInvoice.amountPaid = invoice.amountRequested
113+
if (!updatedInvoice.confirmedAt) {
114+
updatedInvoice.confirmedAt = new Date()
115+
}
116+
updatedInvoice.amountPaid = updatedInvoice.amountRequested
50117

51118
try {
52119
await this.paymentsService.confirmInvoice({
53-
id: invoice.id,
54-
pubkey: invoice.pubkey,
120+
id: updatedInvoice.id,
121+
pubkey: updatedInvoice.pubkey,
55122
status: updatedInvoice.status,
56123
amountPaid: updatedInvoice.amountRequested,
57124
confirmedAt: updatedInvoice.confirmedAt,

0 commit comments

Comments
 (0)