• 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

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

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

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

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

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

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

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

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

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

50
    const body = bodyValidation.value
2✔
51
    debug(
2✔
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
2✔
59
    if (!openNodeApiKey) {
2!
NEW
60
      debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
×
NEW
61
      response
×
62
        .status(500)
63
        .setHeader('content-type', 'text/plain; charset=utf8')
64
        .send('Internal Server Error')
NEW
65
      return
×
66
    }
67

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

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

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

86
    if (
2✔
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> = {
1✔
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'> = {
1✔
106
      id: body.id,
107
      status: statusMap[body.status],
108
    }
109

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

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

UNCOV
118
      throw error
×
119
    }
120

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

126
      return
1✔
127
    }
128

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

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

UNCOV
146
      throw error
×
147
    }
148

UNCOV
149
    response
×
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