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

cameri / nostream / 24609366792

18 Apr 2026 04:52PM UTC coverage: 59.182% (+3.7%) from 55.529%
24609366792

Pull #454

github

web-flow
Merge 695c9c70b into 27d8f8add
Pull Request #454: fix: OpenNode callback accepts unauthenticated requests

775 of 1385 branches covered (55.96%)

Branch coverage included in aggregate %.

44 of 45 new or added lines in 3 files covered. (97.78%)

2 existing lines in 1 file now uncovered.

1974 of 3260 relevant lines covered (60.55%)

9.95 hits per line

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

93.59
/src/controllers/callbacks/opennode-callback-controller.ts
1
import { timingSafeEqual } from 'crypto'
2✔
2

3
import { Request, Response } from 'express'
4

5
import { Invoice, InvoiceStatus } from '../../@types/invoice'
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 { hmacSha256 } from '../../utils/secret'
2✔
10
import { IController } from '../../@types/controllers'
11
import { IPaymentsService } from '../../@types/services'
12
import { opennodeWebhookCallbackBodySchema } from '../../schemas/opennode-callback-schema'
2✔
13
import { validateSchema } from '../../utils/validation'
2✔
14

15
const debug = createLogger('opennode-callback-controller')
2✔
16

17
export class OpenNodeCallbackController implements IController {
2✔
18
  public constructor(
19
    private readonly paymentsService: IPaymentsService,
8✔
20
  ) {}
21

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

28
    const settings = createSettings()
8✔
29
    const remoteAddress = getRemoteAddress(request, settings)
8✔
30
    const paymentProcessor = settings.payments?.processor
8✔
31

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

40
    const bodyValidation = validateSchema(opennodeWebhookCallbackBodySchema)(request.body)
7✔
41
    if (bodyValidation.error) {
7✔
42
      debug('opennode callback request rejected: invalid body %o', bodyValidation.error)
2✔
43
      response
2✔
44
        .status(400)
45
        .setHeader('content-type', 'text/plain; charset=utf8')
46
        .send('Malformed body')
47
      return
2✔
48
    }
49

50
    const body = bodyValidation.value
5✔
51
    debug(
5✔
52
      'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
53
      typeof body.id === 'string',
54
      typeof body.hashed_order === 'string',
55
      body.status,
56
    )
57

58
    const openNodeApiKey = process.env.OPENNODE_API_KEY
5✔
59
    if (!openNodeApiKey) {
5✔
60
      debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
1✔
61
      response
1✔
62
        .status(500)
63
        .setHeader('content-type', 'text/plain; charset=utf8')
64
        .send('Internal Server Error')
65
      return
1✔
66
    }
67

68
    const expectedBuf = hmacSha256(openNodeApiKey, body.id)
4✔
69
    const actualHex = body.hashed_order
4✔
70
    const expectedHexLength = expectedBuf.length * 2
4✔
71

72
    if (
4✔
73
      actualHex.length !== expectedHexLength
7✔
74
      || !/^[0-9a-f]+$/i.test(actualHex)
75
    ) {
76
      debug('invalid hashed_order format from %s to /callbacks/opennode', remoteAddress)
1✔
77
      response
1✔
78
        .status(400)
79
        .setHeader('content-type', 'text/plain; charset=utf8')
80
        .send('Bad Request')
81
      return
1✔
82
    }
83

84
    const actualBuf = Buffer.from(actualHex, 'hex')
3✔
85

86
    if (
3✔
87
      !timingSafeEqual(expectedBuf, actualBuf)
88
    ) {
89
      debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress)
1✔
90
      response
1✔
91
        .status(403)
92
        .send('Forbidden')
93
      return
1✔
94
    }
95

96
    const statusMap: Record<string, InvoiceStatus> = {
2✔
97
      expired: InvoiceStatus.EXPIRED,
98
      refunded: InvoiceStatus.EXPIRED,
99
      unpaid: InvoiceStatus.PENDING,
100
      processing: InvoiceStatus.PENDING,
101
      underpaid: InvoiceStatus.PENDING,
102
      paid: InvoiceStatus.COMPLETED,
103
    }
104

105
    const invoice: Pick<Invoice, 'id' | 'status'> = {
2✔
106
      id: body.id,
107
      status: statusMap[body.status],
108
    }
109

110
    debug('invoice', invoice)
2✔
111

112
    let updatedInvoice: Invoice
113
    try {
2✔
114
      updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
2✔
115
    } catch (error) {
NEW
116
      console.error(`Unable to persist invoice ${invoice.id}`, error)
×
117

118
      throw error
×
119
    }
120

121
    if (updatedInvoice.status !== InvoiceStatus.COMPLETED) {
2✔
122
      response
1✔
123
        .status(200)
124
        .send()
125

126
      return
1✔
127
    }
128

129
    if (!updatedInvoice.confirmedAt) {
1!
130
      updatedInvoice.confirmedAt = new Date()
1✔
131
    }
132
    updatedInvoice.amountPaid = updatedInvoice.amountRequested
1✔
133

134
    try {
1✔
135
      await this.paymentsService.confirmInvoice({
1✔
136
        id: updatedInvoice.id,
137
        pubkey: updatedInvoice.pubkey,
138
        status: updatedInvoice.status,
139
        amountPaid: updatedInvoice.amountPaid,
140
        confirmedAt: updatedInvoice.confirmedAt,
141
      })
142
      await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
1✔
143
    } catch (error) {
UNCOV
144
      console.error(`Unable to confirm invoice ${invoice.id}`, error)
×
145

UNCOV
146
      throw error
×
147
    }
148

149
    response
1✔
150
      .status(200)
151
      .setHeader('content-type', 'text/plain; charset=utf8')
152
      .send('OK')
153
  }
154
}
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