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

cameri / nostream / 24594151087

18 Apr 2026 01:47AM UTC coverage: 50.387% (+1.3%) from 49.12%
24594151087

Pull #454

github

web-flow
Merge a8d26a6d0 into 3cb65a66f
Pull Request #454: fix: OpenNode callback accepts unauthenticated requests

496 of 1088 branches covered (45.59%)

Branch coverage included in aggregate %.

38 of 38 new or added lines in 2 files covered. (100.0%)

3 existing lines in 1 file now uncovered.

1392 of 2659 relevant lines covered (52.35%)

9.5 hits per line

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

93.83
/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

13
const debug = createLogger('opennode-callback-controller')
2✔
14

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

20
  public async handleRequest(
21
    request: Request,
22
    response: Response,
23
  ) {
24
    debug('request headers: %o', request.headers)
8✔
25
    debug(
8✔
26
      'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
27
      typeof request.body?.id === 'string',
28
      typeof request.body?.hashed_order === 'string',
29
      typeof request.body?.status === 'string' ? request.body.status : 'missing',
8✔
30
    )
31

32
    const settings = createSettings()
8✔
33
    const remoteAddress = getRemoteAddress(request, settings)
8✔
34
    const paymentProcessor = settings.payments?.processor
8✔
35

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

44
    const validStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid']
7✔
45

46
    if (
7✔
47
      !request.body
33✔
48
      || typeof request.body.id !== 'string'
49
      || typeof request.body.hashed_order !== 'string'
50
      || typeof request.body.status !== 'string'
51
      || !validStatuses.includes(request.body.status)
52
    ) {
53
      response
2✔
54
        .status(400)
55
        .setHeader('content-type', 'text/plain; charset=utf8')
56
        .send('Bad Request')
57
      return
2✔
58
    }
59

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

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

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

86
    const actualBuf = Buffer.from(actualHex, 'hex')
3✔
87

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

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

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

112
    debug('invoice', invoice)
2✔
113

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

120
      throw error
×
121
    }
122

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

128
      return
1✔
129
    }
130

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

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

UNCOV
148
      throw error
×
149
    }
150

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