Skip to content

Commit 2bd7e76

Browse files
committed
feat: implement strict validation for payment callbacks
Fixes #461
1 parent 12688ec commit 2bd7e76

12 files changed

+286
-18
lines changed

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

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { Request, Response } from 'express'
22

33
import { deriveFromSecret, hmacSha256 } from '../../utils/secret'
44
import { Invoice, InvoiceStatus } from '../../@types/invoice'
5+
import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../schemas/lnbits-callback-schema'
56
import { createLogger } from '../../factories/logger-factory'
67
import { createSettings } from '../../factories/settings-factory'
78
import { getRemoteAddress } from '../../utils/http'
89
import { IController } from '../../@types/controllers'
910
import { IInvoiceRepository } from '../../@types/repositories'
1011
import { IPaymentsService } from '../../@types/services'
12+
import { validateSchema } from '../../utils/validation'
1113

1214
const debug = createLogger('lnbits-callback-controller')
1315

@@ -17,7 +19,6 @@ export class LNbitsCallbackController implements IController {
1719
private readonly invoiceRepository: IInvoiceRepository
1820
) { }
1921

20-
// TODO: Validate
2122
public async handleRequest(
2223
request: Request,
2324
response: Response,
@@ -37,34 +38,46 @@ export class LNbitsCallbackController implements IController {
3738
return
3839
}
3940

40-
let validationPassed = false
41-
42-
if (typeof request.query.hmac === 'string' && request.query.hmac.match(/^[0-9]{1,20}:[0-9a-f]{64}$/)) {
43-
const split = request.query.hmac.split(':')
44-
if (hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') === split[1]) {
45-
if (parseInt(split[0]) > Date.now()) {
46-
validationPassed = true
47-
}
48-
}
41+
const queryValidation = validateSchema(lnbitsCallbackQuerySchema)(request.query)
42+
if (queryValidation.error) {
43+
debug('unauthorized request from %s to /callbacks/lnbits: invalid query %o', remoteAddress, queryValidation.error)
44+
response
45+
.status(403)
46+
.send('Forbidden')
47+
return
4948
}
5049

51-
if (!validationPassed) {
52-
debug('unauthorized request from %s to /callbacks/lnbits', remoteAddress)
50+
const hmac = request.query.hmac as string
51+
const split = hmac.split(':')
52+
const expiryString = split[0]
53+
const expiry = Number(expiryString)
54+
const hasValidSplit = split.length === 2
55+
const hasValidExpiry =
56+
/^\d+$/.test(expiryString) &&
57+
Number.isSafeInteger(expiry)
58+
if (
59+
!hasValidSplit ||
60+
hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), expiryString).toString('hex') !== split[1] ||
61+
!hasValidExpiry ||
62+
expiry <= Date.now()
63+
) {
64+
debug('unauthorized request from %s to /callbacks/lnbits: hmac signature mismatch or expired', remoteAddress)
5365
response
5466
.status(403)
5567
.send('Forbidden')
5668
return
5769
}
5870

59-
const body = request.body
60-
if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) {
71+
const bodyValidation = validateSchema(lnbitsCallbackBodySchema)(request.body)
72+
if (bodyValidation.error) {
6173
response
6274
.status(400)
6375
.setHeader('content-type', 'text/plain; charset=utf8')
6476
.send('Malformed body')
6577
return
6678
}
6779

80+
const body = request.body
6881
const invoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(body.payment_hash)
6982
const storedInvoice = await this.invoiceRepository.findById(body.payment_hash)
7083

@@ -119,4 +132,4 @@ export class LNbitsCallbackController implements IController {
119132
.setHeader('content-type', 'text/plain; charset=utf8')
120133
.send('OK')
121134
}
122-
}
135+
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { fromNodelessInvoice } from '../../utils/transform'
88
import { hmacSha256 } from '../../utils/secret'
99
import { IController } from '../../@types/controllers'
1010
import { IPaymentsService } from '../../@types/services'
11+
import { nodelessCallbackBodySchema } from '../../schemas/nodeless-callback-schema'
12+
import { validateSchema } from '../../utils/validation'
1113

1214
const debug = createLogger('nodeless-callback-controller')
1315

@@ -16,14 +18,23 @@ export class NodelessCallbackController implements IController {
1618
private readonly paymentsService: IPaymentsService,
1719
) {}
1820

19-
// TODO: Validate
2021
public async handleRequest(
2122
request: Request,
2223
response: Response,
2324
) {
2425
debug('callback request headers: %o', request.headers)
2526
debug('callback request body: %O', request.body)
2627

28+
const bodyValidation = validateSchema(nodelessCallbackBodySchema)(request.body)
29+
if (bodyValidation.error) {
30+
debug('nodeless callback request rejected: invalid body %o', bodyValidation.error)
31+
response
32+
.status(400)
33+
.setHeader('content-type', 'application/json; charset=utf8')
34+
.send('{"status":"error","message":"Malformed body"}')
35+
return
36+
}
37+
2738
const settings = createSettings()
2839
const paymentProcessor = settings.payments?.processor
2940

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { createLogger } from '../../factories/logger-factory'
55
import { fromOpenNodeInvoice } from '../../utils/transform'
66
import { IController } from '../../@types/controllers'
77
import { IPaymentsService } from '../../@types/services'
8+
import { opennodeCallbackBodySchema } from '../../schemas/opennode-callback-schema'
9+
import { validateSchema } from '../../utils/validation'
810

911
const debug = createLogger('opennode-callback-controller')
1012

@@ -13,14 +15,23 @@ export class OpenNodeCallbackController implements IController {
1315
private readonly paymentsService: IPaymentsService,
1416
) {}
1517

16-
// TODO: Validate
1718
public async handleRequest(
1819
request: Request,
1920
response: Response,
2021
) {
2122
debug('request headers: %o', request.headers)
2223
debug('request body: %O', request.body)
2324

25+
const bodyValidation = validateSchema(opennodeCallbackBodySchema)(request.body)
26+
if (bodyValidation.error) {
27+
debug('opennode callback request rejected: invalid body %o', bodyValidation.error)
28+
response
29+
.status(400)
30+
.setHeader('content-type', 'text/plain; charset=utf8')
31+
.send('Malformed body')
32+
return
33+
}
34+
2435
const invoice = fromOpenNodeInvoice(request.body)
2536

2637
debug('invoice', invoice)

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { fromZebedeeInvoice } from '../../utils/transform'
77
import { getRemoteAddress } from '../../utils/http'
88
import { IController } from '../../@types/controllers'
99
import { IPaymentsService } from '../../@types/services'
10+
import { validateSchema } from '../../utils/validation'
11+
import { zebedeeCallbackBodySchema } from '../../schemas/zebedee-callback-schema'
1012

1113
const debug = createLogger('zebedee-callback-controller')
1214

@@ -15,14 +17,23 @@ export class ZebedeeCallbackController implements IController {
1517
private readonly paymentsService: IPaymentsService,
1618
) {}
1719

18-
// TODO: Validate
1920
public async handleRequest(
2021
request: Request,
2122
response: Response,
2223
) {
2324
debug('request headers: %o', request.headers)
2425
debug('request body: %O', request.body)
2526

27+
const bodyValidation = validateSchema(zebedeeCallbackBodySchema)(request.body)
28+
if (bodyValidation.error) {
29+
debug('zebedee callback request rejected: invalid body %o', bodyValidation.error)
30+
response
31+
.status(400)
32+
.setHeader('content-type', 'text/plain; charset=utf8')
33+
.send('Malformed body')
34+
return
35+
}
36+
2637
const settings = createSettings()
2738

2839
const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { idSchema } from './base-schema'
2+
import Schema from 'joi'
3+
4+
export const lnbitsCallbackQuerySchema = Schema.object({
5+
hmac: Schema.string().pattern(/^[0-9]{1,20}:[0-9a-f]{64}$/).required(),
6+
}).unknown(false)
7+
8+
export const lnbitsCallbackBodySchema = Schema.object({
9+
payment_hash: idSchema.label('payment_hash').required(),
10+
}).unknown(false)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { pubkeySchema } from './base-schema'
2+
import Schema from 'joi'
3+
4+
export const nodelessCallbackBodySchema = Schema.object({
5+
id: Schema.string(),
6+
uuid: Schema.string().required(),
7+
status: Schema.string().required(),
8+
amount: Schema.number().required(),
9+
metadata: Schema.object({
10+
requestId: pubkeySchema.label('metadata.requestId').required(),
11+
description: Schema.string().optional(),
12+
unit: Schema.string().optional(),
13+
createdAt: Schema.alternatives().try(Schema.string(), Schema.date()).optional(),
14+
}).unknown(true).required(),
15+
}).unknown(false)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { pubkeySchema } from './base-schema'
2+
import Schema from 'joi'
3+
4+
export const opennodeCallbackBodySchema = Schema.object({
5+
id: Schema.string().required(),
6+
status: Schema.string().required(),
7+
order_id: pubkeySchema.label('order_id').required(),
8+
description: Schema.string().allow('').optional(),
9+
amount: Schema.number().optional(),
10+
price: Schema.number().optional(),
11+
created_at: Schema.alternatives().try(Schema.number(), Schema.string()).optional(),
12+
lightning_invoice: Schema.object({
13+
payreq: Schema.string().optional(),
14+
expires_at: Schema.number().optional(),
15+
}).unknown(true).optional(),
16+
lightning: Schema.object({
17+
payreq: Schema.string().optional(),
18+
expires_at: Schema.string().optional(),
19+
}).unknown(true).optional(),
20+
}).unknown(true)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { pubkeySchema } from './base-schema'
2+
import Schema from 'joi'
3+
4+
export const zebedeeCallbackBodySchema = Schema.object({
5+
id: Schema.string().required(),
6+
status: Schema.string().required(),
7+
internalId: pubkeySchema.label('internalId').required(),
8+
amount: Schema.alternatives().try(Schema.string(), Schema.number()).required(),
9+
description: Schema.string().required(),
10+
unit: Schema.string().required(),
11+
expiresAt: Schema.string().optional(),
12+
confirmedAt: Schema.string().optional(),
13+
createdAt: Schema.string().optional(),
14+
invoice: Schema.object({
15+
request: Schema.string().required(),
16+
}).unknown(false).required(),
17+
}).unknown(true)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../../src/schemas/lnbits-callback-schema'
2+
import { expect } from 'chai'
3+
import { validateSchema } from '../../../src/utils/validation'
4+
5+
describe('LNbits Callback Schema', () => {
6+
describe('lnbitsCallbackQuerySchema', () => {
7+
it('returns no error if hmac is valid', () => {
8+
const query = { hmac: '1660306803:fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5' }
9+
const result = validateSchema(lnbitsCallbackQuerySchema)(query)
10+
expect(result.error).to.be.undefined
11+
expect(result.value).to.deep.include(query)
12+
})
13+
14+
it('returns error if hmac is missing', () => {
15+
const result = validateSchema(lnbitsCallbackQuerySchema)({})
16+
expect(result.error).to.have.nested.property('message', '"hmac" is required')
17+
})
18+
19+
it('returns error if hmac format is invalid', () => {
20+
const result = validateSchema(lnbitsCallbackQuerySchema)({ hmac: 'not-an-hmac' })
21+
expect(result.error).to.have.nested.property('message').that.matches(/"hmac" with value "not-an-hmac" fails to match the required pattern/)
22+
})
23+
})
24+
25+
describe('lnbitsCallbackBodySchema', () => {
26+
it('returns no error if payment_hash is valid', () => {
27+
const body = { payment_hash: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5' }
28+
const result = validateSchema(lnbitsCallbackBodySchema)(body)
29+
expect(result.error).to.be.undefined
30+
})
31+
32+
it('returns error if payment_hash is not 64 chars hex', () => {
33+
const result = validateSchema(lnbitsCallbackBodySchema)({ payment_hash: 'abc' })
34+
expect(result.error).to.have.nested.property('message', '"payment_hash" length must be 64 characters long')
35+
})
36+
})
37+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect } from 'chai'
2+
import { nodelessCallbackBodySchema } from '../../../src/schemas/nodeless-callback-schema'
3+
import { validateSchema } from '../../../src/utils/validation'
4+
5+
describe('Nodeless Callback Schema', () => {
6+
describe('nodelessCallbackBodySchema', () => {
7+
const validBody = {
8+
uuid: 'some-uuid',
9+
status: 'paid',
10+
amount: 1000,
11+
metadata: {
12+
requestId: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29',
13+
},
14+
}
15+
16+
it('returns no error if body is valid', () => {
17+
const result = validateSchema(nodelessCallbackBodySchema)(validBody)
18+
expect(result.error).to.be.undefined
19+
})
20+
21+
it('returns no error if body contains additional metadata', () => {
22+
const body = {
23+
...validBody,
24+
metadata: {
25+
...validBody.metadata,
26+
createdAt: '2023-01-01T00:00:00Z',
27+
description: 'test payment',
28+
unit: 'sats',
29+
},
30+
}
31+
const result = validateSchema(nodelessCallbackBodySchema)(body)
32+
expect(result.error).to.be.undefined
33+
})
34+
35+
it('returns error if uuid is missing', () => {
36+
const body = { ...validBody }
37+
delete (body as any).uuid
38+
const result = validateSchema(nodelessCallbackBodySchema)(body)
39+
expect(result.error).to.have.nested.property('message', '"uuid" is required')
40+
})
41+
42+
it('returns error if metadata.requestId is not a valid pubkey', () => {
43+
const body = { ...validBody, metadata: { requestId: 'deadbeef' } }
44+
const result = validateSchema(nodelessCallbackBodySchema)(body)
45+
expect(result.error).to.have.nested.property('message', '"metadata.requestId" length must be 64 characters long')
46+
})
47+
})
48+
})

0 commit comments

Comments
 (0)