• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cameri / nostream / 24597194438

18 Apr 2026 04:47AM UTC coverage: 49.987% (-0.03%) from 50.013%
24597194438

push

github

web-flow
feat: implement strict validation for payment callbacks (#426)

Fixes #461

Co-authored-by: Ricardo Cabral <me@ricardocabral.io>

497 of 1109 branches covered (44.82%)

Branch coverage included in aggregate %.

21 of 52 new or added lines in 8 files covered. (40.38%)

4 existing lines in 3 files now uncovered.

1435 of 2756 relevant lines covered (52.07%)

9.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

10.47
/src/controllers/callbacks/lnbits-callback-controller.ts
1
import { Request, Response } from 'express'
2

3
import { deriveFromSecret, hmacSha256 } from '../../utils/secret'
2✔
4
import { Invoice, InvoiceStatus } from '../../@types/invoice'
2✔
5
import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../schemas/lnbits-callback-schema'
2✔
6
import { createLogger } from '../../factories/logger-factory'
2✔
7
import { createSettings } from '../../factories/settings-factory'
2✔
8
import { getRemoteAddress } from '../../utils/http'
2✔
9
import { IController } from '../../@types/controllers'
10
import { IInvoiceRepository } from '../../@types/repositories'
11
import { IPaymentsService } from '../../@types/services'
12
import { validateSchema } from '../../utils/validation'
2✔
13

14
const debug = createLogger('lnbits-callback-controller')
2✔
15

16
export class LNbitsCallbackController implements IController {
2✔
17
  public constructor(
18
    private readonly paymentsService: IPaymentsService,
×
19
    private readonly invoiceRepository: IInvoiceRepository
×
20
  ) { }
21

22
  public async handleRequest(
23
    request: Request,
24
    response: Response,
25
  ) {
26
    debug('request headers: %o', request.headers)
×
27
    debug('request body: %o', request.body)
×
28

29
    const settings = createSettings()
×
30
    const remoteAddress = getRemoteAddress(request, settings)
×
31
    const paymentProcessor = settings.payments?.processor ?? 'null'
×
32

33
    if (paymentProcessor !== 'lnbits') {
×
34
      debug('denied request from %s to /callbacks/lnbits which is not the current payment processor', remoteAddress)
×
35
      response
×
36
        .status(403)
37
        .send('Forbidden')
38
      return
×
39
    }
40

NEW
41
    const queryValidation = validateSchema(lnbitsCallbackQuerySchema)(request.query)
×
NEW
42
    if (queryValidation.error) {
×
NEW
43
      debug('unauthorized request from %s to /callbacks/lnbits: invalid query %o', remoteAddress, queryValidation.error)
×
NEW
44
      response
×
45
        .status(403)
46
        .send('Forbidden')
NEW
47
      return
×
48
    }
49

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

NEW
71
    const bodyValidation = validateSchema(lnbitsCallbackBodySchema)(request.body)
×
NEW
72
    if (bodyValidation.error) {
×
UNCOV
73
      response
×
74
        .status(400)
75
        .setHeader('content-type', 'text/plain; charset=utf8')
76
        .send('Malformed body')
77
      return
×
78
    }
79

NEW
80
    const body = request.body
×
81
    const invoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(body.payment_hash)
×
82
    const storedInvoice = await this.invoiceRepository.findById(body.payment_hash)
×
83

84
    if (!storedInvoice) {
×
85
      response
×
86
        .status(404)
87
        .setHeader('content-type', 'text/plain; charset=utf8')
88
        .send('No such invoice')
89
      return
×
90
    }
91

92
    try {
×
93
      await this.paymentsService.updateInvoice(invoice)
×
94
    } catch (error) {
95
      console.error(`Unable to persist invoice ${invoice.id}`, error)
×
96

97
      throw error
×
98
    }
99

100
    if (
×
101
      invoice.status !== InvoiceStatus.COMPLETED
×
102
      && !invoice.confirmedAt
103
    ) {
104
      response
×
105
        .status(200)
106
        .send()
107

108
      return
×
109
    }
110

111
    if (storedInvoice.status === InvoiceStatus.COMPLETED) {
×
112
      response
×
113
        .status(409)
114
        .setHeader('content-type', 'text/plain; charset=utf8')
115
        .send('Invoice is already marked paid')
116
      return
×
117
    }
118

119
    invoice.amountPaid = invoice.amountRequested
×
120

121
    try {
×
122
      await this.paymentsService.confirmInvoice(invoice as Invoice)
×
123
      await this.paymentsService.sendInvoiceUpdateNotification(invoice as Invoice)
×
124
    } catch (error) {
125
      console.error(`Unable to confirm invoice ${invoice.id}`, error)
×
126

127
      throw error
×
128
    }
129

130
    response
×
131
      .status(200)
132
      .setHeader('content-type', 'text/plain; charset=utf8')
133
      .send('OK')
134
  }
135
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc