diff --git a/src/controllers/callbacks/lnbits-callback-controller.ts b/src/controllers/callbacks/lnbits-callback-controller.ts index 465e6de0..7aafc9f7 100644 --- a/src/controllers/callbacks/lnbits-callback-controller.ts +++ b/src/controllers/callbacks/lnbits-callback-controller.ts @@ -2,12 +2,14 @@ import { Request, Response } from 'express' import { deriveFromSecret, hmacSha256 } from '../../utils/secret' import { Invoice, InvoiceStatus } from '../../@types/invoice' +import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../schemas/lnbits-callback-schema' import { createLogger } from '../../factories/logger-factory' import { createSettings } from '../../factories/settings-factory' import { getRemoteAddress } from '../../utils/http' import { IController } from '../../@types/controllers' import { IInvoiceRepository } from '../../@types/repositories' import { IPaymentsService } from '../../@types/services' +import { validateSchema } from '../../utils/validation' const debug = createLogger('lnbits-callback-controller') @@ -17,7 +19,6 @@ export class LNbitsCallbackController implements IController { private readonly invoiceRepository: IInvoiceRepository ) { } - // TODO: Validate public async handleRequest( request: Request, response: Response, @@ -37,27 +38,38 @@ export class LNbitsCallbackController implements IController { return } - let validationPassed = false - - if (typeof request.query.hmac === 'string' && request.query.hmac.match(/^[0-9]{1,20}:[0-9a-f]{64}$/)) { - const split = request.query.hmac.split(':') - if (hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') === split[1]) { - if (parseInt(split[0]) > Date.now()) { - validationPassed = true - } - } + const queryValidation = validateSchema(lnbitsCallbackQuerySchema)(request.query) + if (queryValidation.error) { + debug('unauthorized request from %s to /callbacks/lnbits: invalid query %o', remoteAddress, queryValidation.error) + response + .status(403) + .send('Forbidden') + return } - if (!validationPassed) { - debug('unauthorized request from %s to /callbacks/lnbits', remoteAddress) + const hmac = request.query.hmac as string + const split = hmac.split(':') + const expiryString = split[0] + const expiry = Number(expiryString) + const hasValidSplit = split.length === 2 + const hasValidExpiry = + /^\d+$/.test(expiryString) && + Number.isSafeInteger(expiry) + if ( + !hasValidSplit || + hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), expiryString).toString('hex') !== split[1] || + !hasValidExpiry || + expiry <= Date.now() + ) { + debug('unauthorized request from %s to /callbacks/lnbits: hmac signature mismatch or expired', remoteAddress) response .status(403) .send('Forbidden') return } - const body = request.body - if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) { + const bodyValidation = validateSchema(lnbitsCallbackBodySchema)(request.body) + if (bodyValidation.error) { response .status(400) .setHeader('content-type', 'text/plain; charset=utf8') @@ -65,6 +77,7 @@ export class LNbitsCallbackController implements IController { return } + const body = request.body const invoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(body.payment_hash) const storedInvoice = await this.invoiceRepository.findById(body.payment_hash) @@ -119,4 +132,4 @@ export class LNbitsCallbackController implements IController { .setHeader('content-type', 'text/plain; charset=utf8') .send('OK') } -} +} \ No newline at end of file diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index 2e5be0fb..4f6efc1a 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -8,6 +8,8 @@ import { fromNodelessInvoice } from '../../utils/transform' import { hmacSha256 } from '../../utils/secret' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' +import { nodelessCallbackBodySchema } from '../../schemas/nodeless-callback-schema' +import { validateSchema } from '../../utils/validation' const debug = createLogger('nodeless-callback-controller') @@ -16,7 +18,6 @@ export class NodelessCallbackController implements IController { private readonly paymentsService: IPaymentsService, ) {} - // TODO: Validate public async handleRequest( request: Request, response: Response, @@ -24,6 +25,16 @@ export class NodelessCallbackController implements IController { debug('callback request headers: %o', request.headers) debug('callback request body: %O', request.body) + const bodyValidation = validateSchema(nodelessCallbackBodySchema)(request.body) + if (bodyValidation.error) { + debug('nodeless callback request rejected: invalid body %o', bodyValidation.error) + response + .status(400) + .setHeader('content-type', 'application/json; charset=utf8') + .send('{"status":"error","message":"Malformed body"}') + return + } + const settings = createSettings() const paymentProcessor = settings.payments?.processor diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index f3755df0..ed8d704f 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -5,6 +5,8 @@ import { createLogger } from '../../factories/logger-factory' import { fromOpenNodeInvoice } from '../../utils/transform' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' +import { opennodeCallbackBodySchema } from '../../schemas/opennode-callback-schema' +import { validateSchema } from '../../utils/validation' const debug = createLogger('opennode-callback-controller') @@ -13,7 +15,6 @@ export class OpenNodeCallbackController implements IController { private readonly paymentsService: IPaymentsService, ) {} - // TODO: Validate public async handleRequest( request: Request, response: Response, @@ -21,6 +22,16 @@ export class OpenNodeCallbackController implements IController { debug('request headers: %o', request.headers) debug('request body: %O', request.body) + const bodyValidation = validateSchema(opennodeCallbackBodySchema)(request.body) + if (bodyValidation.error) { + debug('opennode callback request rejected: invalid body %o', bodyValidation.error) + response + .status(400) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Malformed body') + return + } + const invoice = fromOpenNodeInvoice(request.body) debug('invoice', invoice) diff --git a/src/controllers/callbacks/zebedee-callback-controller.ts b/src/controllers/callbacks/zebedee-callback-controller.ts index 869861d7..fa372d23 100644 --- a/src/controllers/callbacks/zebedee-callback-controller.ts +++ b/src/controllers/callbacks/zebedee-callback-controller.ts @@ -7,6 +7,8 @@ import { fromZebedeeInvoice } from '../../utils/transform' import { getRemoteAddress } from '../../utils/http' import { IController } from '../../@types/controllers' import { IPaymentsService } from '../../@types/services' +import { validateSchema } from '../../utils/validation' +import { zebedeeCallbackBodySchema } from '../../schemas/zebedee-callback-schema' const debug = createLogger('zebedee-callback-controller') @@ -15,7 +17,6 @@ export class ZebedeeCallbackController implements IController { private readonly paymentsService: IPaymentsService, ) {} - // TODO: Validate public async handleRequest( request: Request, response: Response, @@ -23,6 +24,16 @@ export class ZebedeeCallbackController implements IController { debug('request headers: %o', request.headers) debug('request body: %O', request.body) + const bodyValidation = validateSchema(zebedeeCallbackBodySchema)(request.body) + if (bodyValidation.error) { + debug('zebedee callback request rejected: invalid body %o', bodyValidation.error) + response + .status(400) + .setHeader('content-type', 'text/plain; charset=utf8') + .send('Malformed body') + return + } + const settings = createSettings() const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {} diff --git a/src/schemas/lnbits-callback-schema.ts b/src/schemas/lnbits-callback-schema.ts new file mode 100644 index 00000000..550f4ec7 --- /dev/null +++ b/src/schemas/lnbits-callback-schema.ts @@ -0,0 +1,10 @@ +import { idSchema } from './base-schema' +import Schema from 'joi' + +export const lnbitsCallbackQuerySchema = Schema.object({ + hmac: Schema.string().pattern(/^[0-9]{1,20}:[0-9a-f]{64}$/).required(), +}).unknown(false) + +export const lnbitsCallbackBodySchema = Schema.object({ + payment_hash: idSchema.label('payment_hash').required(), +}).unknown(false) diff --git a/src/schemas/nodeless-callback-schema.ts b/src/schemas/nodeless-callback-schema.ts new file mode 100644 index 00000000..7d5d5d44 --- /dev/null +++ b/src/schemas/nodeless-callback-schema.ts @@ -0,0 +1,15 @@ +import { pubkeySchema } from './base-schema' +import Schema from 'joi' + +export const nodelessCallbackBodySchema = Schema.object({ + id: Schema.string(), + uuid: Schema.string().required(), + status: Schema.string().required(), + amount: Schema.number().required(), + metadata: Schema.object({ + requestId: pubkeySchema.label('metadata.requestId').required(), + description: Schema.string().optional(), + unit: Schema.string().optional(), + createdAt: Schema.alternatives().try(Schema.string(), Schema.date()).optional(), + }).unknown(true).required(), +}).unknown(false) diff --git a/src/schemas/opennode-callback-schema.ts b/src/schemas/opennode-callback-schema.ts new file mode 100644 index 00000000..8081e234 --- /dev/null +++ b/src/schemas/opennode-callback-schema.ts @@ -0,0 +1,20 @@ +import { pubkeySchema } from './base-schema' +import Schema from 'joi' + +export const opennodeCallbackBodySchema = Schema.object({ + id: Schema.string().required(), + status: Schema.string().required(), + order_id: pubkeySchema.label('order_id').required(), + description: Schema.string().allow('').optional(), + amount: Schema.number().optional(), + price: Schema.number().optional(), + created_at: Schema.alternatives().try(Schema.number(), Schema.string()).optional(), + lightning_invoice: Schema.object({ + payreq: Schema.string().optional(), + expires_at: Schema.number().optional(), + }).unknown(true).optional(), + lightning: Schema.object({ + payreq: Schema.string().optional(), + expires_at: Schema.string().optional(), + }).unknown(true).optional(), +}).unknown(true) diff --git a/src/schemas/zebedee-callback-schema.ts b/src/schemas/zebedee-callback-schema.ts new file mode 100644 index 00000000..40d2ce5e --- /dev/null +++ b/src/schemas/zebedee-callback-schema.ts @@ -0,0 +1,17 @@ +import { pubkeySchema } from './base-schema' +import Schema from 'joi' + +export const zebedeeCallbackBodySchema = Schema.object({ + id: Schema.string().required(), + status: Schema.string().required(), + internalId: pubkeySchema.label('internalId').required(), + amount: Schema.alternatives().try(Schema.string(), Schema.number()).required(), + description: Schema.string().required(), + unit: Schema.string().required(), + expiresAt: Schema.string().optional(), + confirmedAt: Schema.string().optional(), + createdAt: Schema.string().optional(), + invoice: Schema.object({ + request: Schema.string().required(), + }).unknown(false).required(), +}).unknown(true) diff --git a/test/unit/schemas/lnbits-callback-schema.spec.ts b/test/unit/schemas/lnbits-callback-schema.spec.ts new file mode 100644 index 00000000..45225d5b --- /dev/null +++ b/test/unit/schemas/lnbits-callback-schema.spec.ts @@ -0,0 +1,37 @@ +import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../../src/schemas/lnbits-callback-schema' +import { expect } from 'chai' +import { validateSchema } from '../../../src/utils/validation' + +describe('LNbits Callback Schema', () => { + describe('lnbitsCallbackQuerySchema', () => { + it('returns no error if hmac is valid', () => { + const query = { hmac: '1660306803:fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5' } + const result = validateSchema(lnbitsCallbackQuerySchema)(query) + expect(result.error).to.be.undefined + expect(result.value).to.deep.include(query) + }) + + it('returns error if hmac is missing', () => { + const result = validateSchema(lnbitsCallbackQuerySchema)({}) + expect(result.error).to.have.nested.property('message', '"hmac" is required') + }) + + it('returns error if hmac format is invalid', () => { + const result = validateSchema(lnbitsCallbackQuerySchema)({ hmac: 'not-an-hmac' }) + expect(result.error).to.have.nested.property('message').that.matches(/"hmac" with value "not-an-hmac" fails to match the required pattern/) + }) + }) + + describe('lnbitsCallbackBodySchema', () => { + it('returns no error if payment_hash is valid', () => { + const body = { payment_hash: 'fa4dd948576fe182f5d0e3120b9df42c83dffa1c884754d5e4d3b0a2f98a01c5' } + const result = validateSchema(lnbitsCallbackBodySchema)(body) + expect(result.error).to.be.undefined + }) + + it('returns error if payment_hash is not 64 chars hex', () => { + const result = validateSchema(lnbitsCallbackBodySchema)({ payment_hash: 'abc' }) + expect(result.error).to.have.nested.property('message', '"payment_hash" length must be 64 characters long') + }) + }) +}) diff --git a/test/unit/schemas/nodeless-callback-schema.spec.ts b/test/unit/schemas/nodeless-callback-schema.spec.ts new file mode 100644 index 00000000..c0ceb18f --- /dev/null +++ b/test/unit/schemas/nodeless-callback-schema.spec.ts @@ -0,0 +1,48 @@ +import { expect } from 'chai' +import { nodelessCallbackBodySchema } from '../../../src/schemas/nodeless-callback-schema' +import { validateSchema } from '../../../src/utils/validation' + +describe('Nodeless Callback Schema', () => { + describe('nodelessCallbackBodySchema', () => { + const validBody = { + uuid: 'some-uuid', + status: 'paid', + amount: 1000, + metadata: { + requestId: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + }, + } + + it('returns no error if body is valid', () => { + const result = validateSchema(nodelessCallbackBodySchema)(validBody) + expect(result.error).to.be.undefined + }) + + it('returns no error if body contains additional metadata', () => { + const body = { + ...validBody, + metadata: { + ...validBody.metadata, + createdAt: '2023-01-01T00:00:00Z', + description: 'test payment', + unit: 'sats', + }, + } + const result = validateSchema(nodelessCallbackBodySchema)(body) + expect(result.error).to.be.undefined + }) + + it('returns error if uuid is missing', () => { + const body = { ...validBody } + delete (body as any).uuid + const result = validateSchema(nodelessCallbackBodySchema)(body) + expect(result.error).to.have.nested.property('message', '"uuid" is required') + }) + + it('returns error if metadata.requestId is not a valid pubkey', () => { + const body = { ...validBody, metadata: { requestId: 'deadbeef' } } + const result = validateSchema(nodelessCallbackBodySchema)(body) + expect(result.error).to.have.nested.property('message', '"metadata.requestId" length must be 64 characters long') + }) + }) +}) diff --git a/test/unit/schemas/opennode-callback-schema.spec.ts b/test/unit/schemas/opennode-callback-schema.spec.ts new file mode 100644 index 00000000..cf8cd277 --- /dev/null +++ b/test/unit/schemas/opennode-callback-schema.spec.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai' +import { opennodeCallbackBodySchema } from '../../../src/schemas/opennode-callback-schema' +import { validateSchema } from '../../../src/utils/validation' + +describe('OpenNode Callback Schema', () => { + describe('opennodeCallbackBodySchema', () => { + const validBody = { + id: 'some-id', + status: 'paid', + order_id: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + } + + it('returns no error if body is valid', () => { + const result = validateSchema(opennodeCallbackBodySchema)(validBody) + expect(result.error).to.be.undefined + }) + + it('returns no error if body contains additional expected fields', () => { + const body = { + ...validBody, + amount: 1000, + created_at: 1672531200, + lightning_invoice: { payreq: 'lnbc1...' }, + } + const result = validateSchema(opennodeCallbackBodySchema)(body) + expect(result.error).to.be.undefined + }) + + it('returns error if order_id is missing', () => { + const body = { ...validBody } + delete (body as any).order_id + const result = validateSchema(opennodeCallbackBodySchema)(body) + expect(result.error).to.have.nested.property('message', '"order_id" is required') + }) + }) +}) diff --git a/test/unit/schemas/zebedee-callback-schema.spec.ts b/test/unit/schemas/zebedee-callback-schema.spec.ts new file mode 100644 index 00000000..75727f5d --- /dev/null +++ b/test/unit/schemas/zebedee-callback-schema.spec.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai' +import { validateSchema } from '../../../src/utils/validation' +import { zebedeeCallbackBodySchema } from '../../../src/schemas/zebedee-callback-schema' + +describe('Zebedee Callback Schema', () => { + describe('zebedeeCallbackBodySchema', () => { + const validBody = { + id: 'some-id', + status: 'completed', + internalId: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', + amount: '1000', + description: 'Test payment', + unit: 'msats', + invoice: { + request: 'lnbc1...', + }, + } + + it('returns no error if body is valid', () => { + const result = validateSchema(zebedeeCallbackBodySchema)(validBody) + expect(result.error).to.be.undefined + }) + + it('returns no error if body contains unknown additional fields', () => { + const body = { + ...validBody, + extraProperty: true, + } + const result = validateSchema(zebedeeCallbackBodySchema)(body) + expect(result.error).to.be.undefined + }) + + it('returns error if internalId is not a valid pubkey', () => { + const body = { ...validBody, internalId: 'deadbeef' } + const result = validateSchema(zebedeeCallbackBodySchema)(body) + expect(result.error).to.have.nested.property('message', '"internalId" length must be 64 characters long') + }) + }) +})