FIXES TODO in callbacks: implement strict validation for all payment callbacks#426
FIXES TODO in callbacks: implement strict validation for all payment callbacks#426Mohit-Davar wants to merge 1 commit intocameri:mainfrom
Conversation
|
@Mohit-Davar Appreciate your work here. Could you please create the issue for this PR? |
| @@ -0,0 +1,32 @@ | |||
| import { idSchema, pubkeySchema } from './base-schema' | |||
There was a problem hiding this comment.
Could you please split this file into one per callback controller?
There was a problem hiding this comment.
Pull request overview
This PR adds Joi-based request validation for incoming payment processor webhook callbacks (LNbits, Nodeless, OpenNode, Zebedee) by introducing centralized callback schemas and enforcing schema validation at the start of each callback controller.
Changes:
- Added
src/schemas/callback-request-schemas.tsto define Joi schemas for supported callback payloads/query params. - Updated each callback controller to validate incoming request bodies (and LNbits query) before running business logic.
- Added a unit test suite covering the new callback schemas.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| test/unit/schemas/callback-request-schemas.spec.ts | Adds unit tests for the new callback request schemas. |
| src/schemas/callback-request-schemas.ts | Introduces Joi schemas for LNbits, Nodeless, OpenNode, and Zebedee callback payloads. |
| src/controllers/callbacks/zebedee-callback-controller.ts | Rejects requests with invalid Zebedee callback bodies (400) before processing. |
| src/controllers/callbacks/opennode-callback-controller.ts | Rejects requests with invalid OpenNode callback bodies (400) before processing. |
| src/controllers/callbacks/nodeless-callback-controller.ts | Rejects requests with invalid Nodeless callback bodies (400) before signature/processing. |
| src/controllers/callbacks/lnbits-callback-controller.ts | Validates LNbits callback query/body and replaces manual query/body checks with schema validation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| uuid: Schema.string(), | ||
| status: Schema.string().required(), | ||
| amount: Schema.number().required(), | ||
| metadata: Schema.object({ | ||
| requestId: pubkeySchema.label('metadata.requestId').required(), | ||
| }).unknown(false).required(), | ||
| }).unknown(false).or('id', 'uuid') |
There was a problem hiding this comment.
nodelessCallbackBodySchema allows requests that have id but no uuid (.or('id','uuid')), but NodelessCallbackController builds the invoice id from request.body.uuid (via prop('uuid')). That means a body that passes this schema can still produce an undefined invoice id and break downstream processing. Align the schema with the controller (e.g., require uuid, or update the controller to consistently use id/uuid).
| uuid: Schema.string(), | |
| status: Schema.string().required(), | |
| amount: Schema.number().required(), | |
| metadata: Schema.object({ | |
| requestId: pubkeySchema.label('metadata.requestId').required(), | |
| }).unknown(false).required(), | |
| }).unknown(false).or('id', 'uuid') | |
| uuid: Schema.string().required(), | |
| status: Schema.string().required(), | |
| amount: Schema.number().required(), | |
| metadata: Schema.object({ | |
| requestId: pubkeySchema.label('metadata.requestId').required(), | |
| }).unknown(false).required(), | |
| }).unknown(false) |
| status: Schema.string().required(), | ||
| amount: Schema.number().required(), | ||
| metadata: Schema.object({ | ||
| requestId: pubkeySchema.label('metadata.requestId').required(), |
There was a problem hiding this comment.
nodelessCallbackBodySchema sets metadata to unknown(false) and only permits requestId, but the callback controller later reads metadata.createdAt (and the payments processor sets additional metadata fields like description, unit, createdAt). With unknown(false), any real callback including these expected metadata keys will be rejected as malformed. Update the schema to allow/validate the additional metadata fields that the code expects (or allow unknown keys within metadata).
| requestId: pubkeySchema.label('metadata.requestId').required(), | |
| requestId: pubkeySchema.label('metadata.requestId').required(), | |
| description: Schema.string().optional(), | |
| unit: Schema.string().optional(), | |
| createdAt: Schema.alternatives().try(Schema.string(), Schema.date()).optional(), |
| export const opennodeCallbackBodySchema = Schema.object({ | ||
| id: Schema.string().required(), | ||
| status: Schema.string().required(), | ||
| order_id: pubkeySchema.label('order_id').required(), | ||
| }).unknown(false) |
There was a problem hiding this comment.
opennodeCallbackBodySchema uses unknown(false) and only allows {id,status,order_id}, but OpenNodeCallbackController passes the entire body into fromOpenNodeInvoice, which reads additional fields like amount/price, created_at, and nested lightning_invoice/lightning paths. As written, any payload containing the fields needed to build an invoice will be rejected by the schema, and payloads matching the schema are likely to cause runtime errors (e.g., BigInt(undefined) when amount/price are missing). Expand the schema to validate the fields actually consumed by fromOpenNodeInvoice (and/or allow unknown keys).
| export const opennodeCallbackBodySchema = Schema.object({ | |
| id: Schema.string().required(), | |
| status: Schema.string().required(), | |
| order_id: pubkeySchema.label('order_id').required(), | |
| }).unknown(false) | |
| const opennodeAmountSchema = Schema.alternatives().try( | |
| Schema.number(), | |
| Schema.string().pattern(/^\d+$/), | |
| ) | |
| const opennodeLightningDetailsSchema = Schema.object({ | |
| payreq: Schema.string(), | |
| payment_request: Schema.string(), | |
| }).unknown(true).or('payreq', 'payment_request') | |
| export const opennodeCallbackBodySchema = Schema.object({ | |
| id: Schema.string().required(), | |
| status: Schema.string().required(), | |
| order_id: pubkeySchema.label('order_id').required(), | |
| amount: opennodeAmountSchema, | |
| price: opennodeAmountSchema, | |
| created_at: Schema.alternatives().try( | |
| Schema.date().iso(), | |
| Schema.number(), | |
| Schema.string(), | |
| ).required(), | |
| lightning_invoice: opennodeLightningDetailsSchema, | |
| lightning: opennodeLightningDetailsSchema, | |
| }).or('amount', 'price').or('lightning_invoice', 'lightning').unknown(true) |
| export const zebedeeCallbackBodySchema = Schema.object({ | ||
| id: Schema.string().required(), | ||
| status: Schema.string().required(), | ||
| internalId: pubkeySchema.label('internalId').required(), |
There was a problem hiding this comment.
zebedeeCallbackBodySchema uses unknown(false) and only validates {id,status,internalId}, but ZebedeeCallbackController passes the full body into fromZebedeeInvoice, which expects additional properties like amount, invoice.request, description, and unit. With unknown(false), valid callback payloads containing these fields will be rejected, and payloads that pass this schema may still throw during transformation (e.g., BigInt(undefined) when amount is missing). Update the schema to include/require the fields actually used by fromZebedeeInvoice (and/or allow unknown keys).
| internalId: pubkeySchema.label('internalId').required(), | |
| internalId: pubkeySchema.label('internalId').required(), | |
| amount: Schema.alternatives().try(Schema.string(), Schema.number()).required(), | |
| description: Schema.string().required(), | |
| unit: Schema.string().required(), | |
| invoice: Schema.object({ | |
| request: Schema.string().required(), | |
| }).unknown(false).required(), |
| if ( | ||
| hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') !== split[1] || | ||
| parseInt(split[0]) <= Date.now() |
There was a problem hiding this comment.
lnbitsCallbackQuerySchema allows 1–20 digit expiries, but the controller parses the prefix with parseInt(...) and compares it to Date.now() (ms). Very large values can exceed Number.MAX_SAFE_INTEGER and make the expiry check unreliable, and parseInt is used without an explicit radix. Consider tightening the schema to the expected Date.now() millisecond format (and safe integer range) and parse with Number(...) / Number.isSafeInteger(...) (or parseInt(..., 10)) before comparing.
| if ( | |
| hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') !== split[1] || | |
| parseInt(split[0]) <= Date.now() | |
| 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() |
| describe('nodelessCallbackBodySchema', () => { | ||
| const validBody = { | ||
| id: 'some-uuid', | ||
| status: 'paid', | ||
| amount: 1000, | ||
| metadata: { | ||
| requestId: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', | ||
| }, | ||
| } | ||
|
|
||
| it('returns no error if body is valid with id', () => { | ||
| const result = validateSchema(nodelessCallbackBodySchema)(validBody) | ||
| expect(result.error).to.be.undefined | ||
| }) | ||
|
|
||
| it('returns no error if body is valid with uuid', () => { | ||
| const body = { ...validBody, uuid: 'some-uuid' } | ||
| delete (body as any).id | ||
| const result = validateSchema(nodelessCallbackBodySchema)(body) | ||
| expect(result.error).to.be.undefined | ||
| }) |
There was a problem hiding this comment.
This test treats a Nodeless callback body containing only id (and no uuid) as valid, but the callback controller constructs the invoice id from request.body.uuid. If uuid is truly required for callbacks, update the test (and schema) to reflect that; otherwise, the controller logic should be updated to accept id too.
| 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 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') | ||
| }) | ||
| }) |
There was a problem hiding this comment.
The OpenNode schema test only validates {id,status,order_id}, but the callback handler immediately calls fromOpenNodeInvoice(request.body), which expects additional fields (e.g., amount/price, and lightning invoice nested data). Add test cases that reflect a realistic callback payload that can actually be transformed into an Invoice, and ensure the schema allows/validates those required fields.
| describe('zebedeeCallbackBodySchema', () => { | ||
| const validBody = { | ||
| id: 'some-id', | ||
| status: 'completed', | ||
| internalId: 'edfa27d49d2af37ee331e1225bb6ed1912c6d999281b36d8018ad99bc3573c29', | ||
| } | ||
|
|
||
| it('returns no error if body is valid', () => { | ||
| const result = validateSchema(zebedeeCallbackBodySchema)(validBody) | ||
| 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') | ||
| }) |
There was a problem hiding this comment.
The Zebedee schema test only validates {id,status,internalId}, but the callback handler transforms the body via fromZebedeeInvoice, which expects additional fields like amount and nested invoice.request. Add tests for a realistic callback payload and ensure the schema validates (and permits) the fields needed by the transform.
3acb533 to
50c02ce
Compare
50c02ce to
2bd7e76
Compare
|
@Mohit-Davar kindly create the associated issue for this PR 🙏 |
Description
This pull request implements strict request validation for all supported payment processor callbacks (LNbits, Nodeless, OpenNode, and Zebedee).
Changes include:
Related Issue
Resolves internal TODO: Validate markers in src/controllers/callbacks/.
Motivation and Context
The payment callback endpoints were previously processing external data without strict validation. This change ensures that all incoming webhooks from external processors are verified for correct structure and data types before any business logic is executed.
How Has This Been Tested?
I have implemented a unit test suite in callback-request-schemas.spec.ts
Screenshots (if appropriate):
Implemented these TODOs:
Test Cases:
Types of changes
Checklist: