Skip to content

Commit 695c9c7

Browse files
fix: validate opennode callback body with zod
1 parent f1daf3e commit 695c9c7

4 files changed

Lines changed: 62 additions & 25 deletions

File tree

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

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { getRemoteAddress } from '../../utils/http'
99
import { hmacSha256 } from '../../utils/secret'
1010
import { IController } from '../../@types/controllers'
1111
import { IPaymentsService } from '../../@types/services'
12+
import { opennodeWebhookCallbackBodySchema } from '../../schemas/opennode-callback-schema'
13+
import { validateSchema } from '../../utils/validation'
1214

1315
const debug = createLogger('opennode-callback-controller')
1416

@@ -22,12 +24,6 @@ export class OpenNodeCallbackController implements IController {
2224
response: Response,
2325
) {
2426
debug('request headers: %o', request.headers)
25-
debug(
26-
'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
27-
typeof request.body?.id === 'string',
28-
typeof request.body?.hashed_order === 'string',
29-
typeof request.body?.status === 'string' ? request.body.status : 'missing',
30-
)
3127

3228
const settings = createSettings()
3329
const remoteAddress = getRemoteAddress(request, settings)
@@ -41,22 +37,24 @@ export class OpenNodeCallbackController implements IController {
4137
return
4238
}
4339

44-
const validStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid']
45-
46-
if (
47-
!request.body
48-
|| typeof request.body.id !== 'string'
49-
|| typeof request.body.hashed_order !== 'string'
50-
|| typeof request.body.status !== 'string'
51-
|| !validStatuses.includes(request.body.status)
52-
) {
40+
const bodyValidation = validateSchema(opennodeWebhookCallbackBodySchema)(request.body)
41+
if (bodyValidation.error) {
42+
debug('opennode callback request rejected: invalid body %o', bodyValidation.error)
5343
response
5444
.status(400)
5545
.setHeader('content-type', 'text/plain; charset=utf8')
56-
.send('Bad Request')
46+
.send('Malformed body')
5747
return
5848
}
5949

50+
const body = bodyValidation.value
51+
debug(
52+
'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
53+
typeof body.id === 'string',
54+
typeof body.hashed_order === 'string',
55+
body.status,
56+
)
57+
6058
const openNodeApiKey = process.env.OPENNODE_API_KEY
6159
if (!openNodeApiKey) {
6260
debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
@@ -67,8 +65,8 @@ export class OpenNodeCallbackController implements IController {
6765
return
6866
}
6967

70-
const expectedBuf = hmacSha256(openNodeApiKey, request.body.id)
71-
const actualHex = request.body.hashed_order
68+
const expectedBuf = hmacSha256(openNodeApiKey, body.id)
69+
const actualHex = body.hashed_order
7270
const expectedHexLength = expectedBuf.length * 2
7371

7472
if (
@@ -105,8 +103,8 @@ export class OpenNodeCallbackController implements IController {
105103
}
106104

107105
const invoice: Pick<Invoice, 'id' | 'status'> = {
108-
id: request.body.id,
109-
status: statusMap[request.body.status],
106+
id: body.id,
107+
status: statusMap[body.status],
110108
}
111109

112110
debug('invoice', invoice)

src/schemas/opennode-callback-schema.ts

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

4+
const openNodeCallbackStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid'] as const
5+
6+
export const opennodeWebhookCallbackBodySchema = z.object({
7+
id: z.string(),
8+
hashed_order: z.string(),
9+
status: z.enum(openNodeCallbackStatuses),
10+
}).passthrough()
11+
412
export const opennodeCallbackBodySchema = z.object({
513
id: z.string(),
614
status: z.string(),

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('OpenNodeCallbackController', () => {
8787
expect(updateInvoiceStatusStub).not.to.have.been.called
8888
})
8989

90-
it('returns bad request for malformed callback bodies', async () => {
90+
it('returns malformed body for invalid callback bodies', async () => {
9191
request.body = {
9292
id: 'invoice-id',
9393
}
@@ -96,11 +96,11 @@ describe('OpenNodeCallbackController', () => {
9696

9797
expect(statusStub).to.have.been.calledOnceWithExactly(400)
9898
expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
99-
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
99+
expect(sendStub).to.have.been.calledOnceWithExactly('Malformed body')
100100
expect(updateInvoiceStatusStub).not.to.have.been.called
101101
})
102102

103-
it('returns bad request for unknown status values', async () => {
103+
it('returns malformed body for unknown status values', async () => {
104104
request.body = {
105105
hashed_order: 'some-hash',
106106
id: 'invoice-id',
@@ -110,7 +110,7 @@ describe('OpenNodeCallbackController', () => {
110110
await controller.handleRequest(request, response)
111111

112112
expect(statusStub).to.have.been.calledOnceWithExactly(400)
113-
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
113+
expect(sendStub).to.have.been.calledOnceWithExactly('Malformed body')
114114
expect(updateInvoiceStatusStub).not.to.have.been.called
115115
})
116116

test/unit/schemas/opennode-callback-schema.spec.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,39 @@
1+
import { opennodeCallbackBodySchema, opennodeWebhookCallbackBodySchema } from '../../../src/schemas/opennode-callback-schema'
12
import { expect } from 'chai'
2-
import { opennodeCallbackBodySchema } from '../../../src/schemas/opennode-callback-schema'
33
import { validateSchema } from '../../../src/utils/validation'
44

55
describe('OpenNode Callback Schema', () => {
6+
describe('opennodeWebhookCallbackBodySchema', () => {
7+
const validWebhookBody = {
8+
hashed_order: 'a'.repeat(64),
9+
id: 'some-id',
10+
status: 'paid',
11+
}
12+
13+
it('returns no error if webhook body is valid', () => {
14+
const result = validateSchema(opennodeWebhookCallbackBodySchema)(validWebhookBody)
15+
expect(result.error).to.be.undefined
16+
})
17+
18+
it('returns error if hashed_order is missing', () => {
19+
const body = { ...validWebhookBody }
20+
delete (body as any).hashed_order
21+
const result = validateSchema(opennodeWebhookCallbackBodySchema)(body)
22+
expect(result.error).to.exist
23+
expect(result.error?.issues[0].path).to.deep.equal(['hashed_order'])
24+
})
25+
26+
it('returns error if status is not in accepted values', () => {
27+
const body = {
28+
...validWebhookBody,
29+
status: 'not-a-valid-status',
30+
}
31+
const result = validateSchema(opennodeWebhookCallbackBodySchema)(body)
32+
expect(result.error).to.exist
33+
expect(result.error?.issues[0].path).to.deep.equal(['status'])
34+
})
35+
})
36+
637
describe('opennodeCallbackBodySchema', () => {
738
const validBody = {
839
id: 'some-id',

0 commit comments

Comments
 (0)