Skip to content

FIXES TODO in callbacks: implement strict validation for all payment callbacks#426

Open
Mohit-Davar wants to merge 1 commit intocameri:mainfrom
Mohit-Davar:feat/callback-validation
Open

FIXES TODO in callbacks: implement strict validation for all payment callbacks#426
Mohit-Davar wants to merge 1 commit intocameri:mainfrom
Mohit-Davar:feat/callback-validation

Conversation

@Mohit-Davar
Copy link
Copy Markdown
Contributor

Description

This pull request implements strict request validation for all supported payment processor callbacks (LNbits, Nodeless, OpenNode, and Zebedee).

Changes include:

  • Centralised Schema Layer: Created callback-request-schemas.ts to isolate and manage third-party webhook structures.
  • Joi based verification: Instead of relying on manual verification for every callback (like manually checking hex patterns or string lengths), I've moved everything to use Joi schemas and bringing the callback logic in line with how the rest of the project handles validation.

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:

image

Test Cases:

image

Types of changes

  • Non-functional change (docs, style, minor refactor)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my code changes.
  • All new and existing tests passed.

@Mohit-Davar Mohit-Davar changed the title FIXES TODO in implement strict validation for all payment callbacks FIXES TODO in callbacks: implement strict validation for all payment callbacks Apr 9, 2026
@cameri
Copy link
Copy Markdown
Owner

cameri commented Apr 10, 2026

@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'
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please split this file into one per callback controller?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts to 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.

Comment on lines +14 to +20
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')
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
status: Schema.string().required(),
amount: Schema.number().required(),
metadata: Schema.object({
requestId: pubkeySchema.label('metadata.requestId').required(),
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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(),

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +26
export const opennodeCallbackBodySchema = Schema.object({
id: Schema.string().required(),
status: Schema.string().required(),
order_id: pubkeySchema.label('order_id').required(),
}).unknown(false)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
export const zebedeeCallbackBodySchema = Schema.object({
id: Schema.string().required(),
status: Schema.string().required(),
internalId: pubkeySchema.label('internalId').required(),
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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(),

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +54
if (
hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') !== split[1] ||
parseInt(split[0]) <= Date.now()
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +64
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
})
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +98
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')
})
})
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +116
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')
})
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@Mohit-Davar Mohit-Davar force-pushed the feat/callback-validation branch 2 times, most recently from 3acb533 to 50c02ce Compare April 11, 2026 07:30
@Mohit-Davar Mohit-Davar force-pushed the feat/callback-validation branch from 50c02ce to 2bd7e76 Compare April 11, 2026 07:31
@cameri
Copy link
Copy Markdown
Owner

cameri commented Apr 11, 2026

@Mohit-Davar kindly create the associated issue for this PR 🙏

@cameri cameri linked an issue Apr 11, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement strict validation for payment callback webhooks

3 participants