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

cameri / nostream / 24609919997

18 Apr 2026 05:23PM UTC coverage: 30.829% (-32.0%) from 62.807%
24609919997

Pull #454

github

web-flow
Merge c337d7af3 into 26bcdd51d
Pull Request #454: fix: OpenNode callback accepts unauthenticated requests

268 of 1385 branches covered (19.35%)

Branch coverage included in aggregate %.

29 of 41 new or added lines in 3 files covered. (70.73%)

976 existing lines in 41 files now uncovered.

1164 of 3260 relevant lines covered (35.71%)

5.89 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'
1✔
4
import { Invoice, InvoiceStatus } from '../../@types/invoice'
1✔
5
import { lnbitsCallbackBodySchema, lnbitsCallbackQuerySchema } from '../../schemas/lnbits-callback-schema'
1✔
6
import { createLogger } from '../../factories/logger-factory'
1✔
7
import { createSettings } from '../../factories/settings-factory'
1✔
8
import { getRemoteAddress } from '../../utils/http'
1✔
9
import { IController } from '../../@types/controllers'
10
import { IInvoiceRepository } from '../../@types/repositories'
11
import { IPaymentsService } from '../../@types/services'
12
import { validateSchema } from '../../utils/validation'
1✔
13

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

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

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

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

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

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

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

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

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

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

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

UNCOV
97
      throw error
×
98
    }
99

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

UNCOV
108
      return
×
109
    }
110

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

UNCOV
119
    invoice.amountPaid = invoice.amountRequested
×
120

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

UNCOV
127
      throw error
×
128
    }
129

UNCOV
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