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

haraka / haraka-plugin-relay / 26704917778

31 May 2026 06:03AM UTC coverage: 84.127% (-2.0%) from 86.084%
26704917778

push

github

web-flow
test: refactored against test-fixtures 1.7.0 (#5)

- fix: guard `JSON.parse` in dest_domains and force_routing

56 of 71 branches covered (78.87%)

Branch coverage included in aggregate %.

20 of 34 new or added lines in 1 file covered. (58.82%)

209 of 244 relevant lines covered (85.66%)

3.46 hits per line

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

84.13
/index.js
1
// relay
1✔
2
//
1✔
3
// documentation via: haraka -h relay
1✔
4

1✔
5
const net = require('node:net')
1✔
6

1✔
7
const ipaddr = require('ipaddr.js')
1✔
8

1✔
9
exports.register = function () {
1✔
10
  this.load_relay_ini() // plugin.cfg = { }
2✔
11

2✔
12
  if (this.cfg.relay.acl) {
2✔
13
    this.load_acls() // plugin.acl_allow = [..]
2✔
14
    this.register_hook('connect_init', 'acl')
2✔
15
    this.register_hook('connect', 'pass_relaying')
2✔
16
  }
2✔
17

2✔
18
  if (this.cfg.relay.force_routing || this.cfg.relay.dest_domains) {
2!
19
    this.load_dest_domains() // plugin.dest.domains = { }
2✔
20
  }
2✔
21

2✔
22
  if (this.cfg.relay.force_routing) {
2✔
23
    this.register_hook('get_mx', 'force_routing')
2✔
24
  }
2✔
25

2✔
26
  if (this.cfg.relay.dest_domains) {
2!
27
    this.register_hook('rcpt', 'dest_domains')
×
28
  }
×
29

2✔
30
  if (this.cfg.relay.all) {
2!
31
    this.register_hook('rcpt', 'all')
×
32
  }
×
33
}
2✔
34

1✔
35
exports.load_relay_ini = function () {
1✔
36
  this.cfg = this.config.get(
3✔
37
    'relay.ini',
3✔
38
    {
3✔
39
      booleans: [
3✔
40
        '+relay.acl',
3✔
41
        '+relay.force_routing',
3✔
42
        '-relay.all',
3✔
43
        '-relay.dest_domains',
3✔
44
      ],
3✔
45
    },
3✔
46
    () => {
3✔
47
      this.load_relay_ini()
×
48
    },
3✔
49
  )
3✔
50
}
3✔
51

1✔
52
exports.load_dest_domains = function () {
1✔
53
  this.dest = this.config.get('relay_dest_domains.ini', 'ini', () => {
3✔
54
    this.load_dest_domains()
×
55
  })
3✔
56
}
3✔
57

1✔
58
exports.load_acls = function () {
1✔
59
  const file_name = 'relay_acl_allow'
7✔
60

7✔
61
  // load with a self-referential callback
7✔
62
  this.acl_allow = this.config.get(file_name, 'list', () => {
7✔
63
    this.load_acls()
×
64
  })
7✔
65

7✔
66
  for (let i = this.acl_allow.length - 1; i >= 0; i--) {
7✔
67
    // Strip inline comments (e.g. "8.8.8.8/32 # note" → "8.8.8.8/32")
8✔
68
    const entry = this.acl_allow[i].split('#')[0].trim()
8✔
69
    if (!entry) {
8✔
70
      this.acl_allow.splice(i, 1)
1✔
71
      continue
1✔
72
    }
1✔
73

7✔
74
    const cidr = entry.split('/')
7✔
75
    if (!net.isIP(cidr[0])) {
8✔
76
      this.logerror(this, `invalid entry in ${file_name}: ${cidr[0]}`)
1✔
77
      this.acl_allow.splice(i, 1)
1✔
78
      continue
1✔
79
    }
1✔
80

6✔
81
    if (!cidr[1]) {
8✔
82
      this.logerror(this, `appending missing CIDR suffix in: ${file_name}`)
1✔
83
      this.acl_allow[i] = `${cidr[0]}/32`
1✔
84
    } else {
8✔
85
      this.acl_allow[i] = entry
5✔
86
    }
5✔
87
  }
8✔
88
}
7✔
89

1✔
90
exports.acl = function (next, connection) {
1✔
91
  if (!this.cfg.relay.acl) return next()
5✔
92

4✔
93
  connection.logdebug(
4✔
94
    this,
4✔
95
    `checking ${connection.remote.ip} in relay_acl_allow`,
4✔
96
  )
4✔
97

4✔
98
  if (!this.is_acl_allowed(connection)) {
5✔
99
    connection.results.add(this, { skip: 'acl(unlisted)' })
1✔
100
    return next()
1✔
101
  }
1✔
102

3✔
103
  connection.results.add(this, { pass: 'acl' })
3✔
104
  connection.relaying = true
3✔
105
  return next(OK)
3✔
106
}
5✔
107

1✔
108
exports.pass_relaying = (next, connection) => {
1✔
109
  if (connection.relaying) return next(OK)
5✔
110
  next()
2✔
111
}
5✔
112

1✔
113
exports.is_acl_allowed = function (connection) {
1✔
114
  if (!this.acl_allow?.length) return false
14✔
115

12✔
116
  const { ip } = connection.remote
12✔
117

12✔
118
  for (const item of this.acl_allow) {
12✔
119
    connection.logdebug(this, `checking if ${ip} is in ${item}`)
12✔
120
    const cidr = item.split('/')
12✔
121
    const c_net = cidr[0]
12✔
122
    const c_mask = parseInt(cidr[1], 10) || (net.isIPv6(c_net) ? 128 : 32)
12!
123

12✔
124
    if (!net.isIP(c_net)) continue // bad config entry
12!
125
    if (net.isIPv4(ip) && net.isIPv6(c_net)) continue
12!
126
    if (net.isIPv6(ip) && net.isIPv4(c_net)) continue
12✔
127

11✔
128
    if (ipaddr.parse(ip).match(ipaddr.parse(c_net), c_mask)) {
12✔
129
      connection.logdebug(this, `checking if ${ip} is in ${item}: yes`)
7✔
130
      return true
7✔
131
    }
7✔
132
  }
12✔
133
  return false
5✔
134
}
14✔
135

1✔
136
exports.dest_domains = function (next, connection, params) {
1✔
137
  if (!this.cfg.relay.dest_domains) return next()
7✔
138

6✔
139
  const { relaying, transaction } = connection ?? {}
7!
140
  if (!transaction) return next()
7!
141

6✔
142
  // Skip this if the host is already allowed to relay
6✔
143
  if (relaying) {
7✔
144
    transaction.results.add(this, { skip: 'relay_dest_domain(relay)' })
1✔
145
    return next()
1✔
146
  }
1✔
147

5✔
148
  if (!this.dest) {
7✔
149
    transaction.results.add(this, { err: 'relay_dest_domain(no config!)' })
1✔
150
    return next()
1✔
151
  }
1✔
152

4✔
153
  if (!this.dest.domains) {
7!
154
    transaction.results.add(this, { skip: 'relay_dest_domain(config)' })
×
155
    return next()
×
156
  }
×
157

4✔
158
  const dest_domain = params[0].host
4✔
159
  connection.logdebug(this, `dest_domain = ${dest_domain}`)
4✔
160

4✔
161
  const dst_cfg = this.dest.domains[dest_domain]
4✔
162
  if (!dst_cfg) {
7!
163
    transaction.results.add(this, { fail: 'relay_dest_domain' })
×
164
    return next(DENY, 'You are not allowed to relay')
×
165
  }
×
166

4✔
167
  // guard JSON parsing — a malformed entry in relay_dest_domains.ini
4✔
168
  // should fail closed (deny relay) and surface a config error, not throw.
4✔
169
  let parsed
4✔
170
  try {
4✔
171
    parsed = JSON.parse(dst_cfg)
4✔
172
  } catch (err) {
7!
NEW
173
    connection.logerror(
×
NEW
174
      this,
×
NEW
175
      `relay_dest_domains[${dest_domain}] is not valid JSON: ${err.message}`,
×
NEW
176
    )
×
NEW
177
    transaction.results.add(this, { err: `relay_dest_domain(invalid_json)` })
×
NEW
178
    return next(DENYSOFT, 'Relay config error')
×
NEW
179
  }
×
180
  const { action } = parsed
4✔
181
  connection.logdebug(this, `found config for ${dest_domain}: ${action}`)
4✔
182

4✔
183
  switch (action) {
4✔
184
    case 'accept':
7!
185
      // why enable relaying here? Returning next(OK) will allow the
×
186
      // address to be considered 'local'. What advantage does relaying
×
187
      // bring?
×
188
      connection.relaying = true
×
189
      transaction.results.add(this, { pass: 'relay_dest_domain' })
×
190
      return next(OK)
×
191
    case 'continue':
7✔
192
      // why oh why? Only reason I can think of is to enable outbound.
2✔
193
      connection.relaying = true
2✔
194
      transaction.results.add(this, { pass: 'relay_dest_domain' })
2✔
195
      return next(CONT) // same as next()
7✔
196
    case 'deny':
7✔
197
      transaction.results.add(this, { fail: 'relay_dest_domain' })
1✔
198
      return next(DENY, 'You are not allowed to relay')
1✔
199
  }
7✔
200

1✔
201
  transaction.results.add(this, { fail: 'relay_dest_domain' })
1✔
202
  next(DENY, 'Mail for that recipient is not accepted here.')
1✔
203
}
7✔
204

1✔
205
exports.force_routing = function (next, hmail, domain) {
1✔
206
  if (!this.cfg.relay.force_routing) return next()
5✔
207
  if (!this.dest?.domains) return next()
5✔
208
  let route = this.dest.domains[domain]
3✔
209

3✔
210
  if (!route) {
5✔
211
    route = this.dest.domains.any
1✔
212
    if (!route) {
1!
NEW
213
      this.logdebug(this, `using MX lookup for: ${domain}`)
×
214
      return next()
×
215
    }
×
216
  }
1✔
217

3✔
218
  // guard JSON parse — fall back to MX lookup
3✔
219
  let nexthop
3✔
220
  try {
3✔
221
    ;({ nexthop } = JSON.parse(route))
3✔
222
  } catch (err) {
5!
NEW
223
    this.logerror(
×
NEW
224
      this,
×
NEW
225
      `force_routing: invalid JSON for ${domain}: ${err.message}`,
×
NEW
226
    )
×
NEW
227
    return next()
×
NEW
228
  }
×
229
  if (!nexthop) {
5✔
230
    this.logdebug(this, `using MX lookup for: ${domain}`)
1✔
231
    return next()
1✔
232
  }
1✔
233

2✔
234
  this.logdebug(this, `using ${nexthop} for: ${domain}`)
2✔
235
  next(OK, nexthop)
2✔
236
}
5✔
237

1✔
238
exports.all = function (next, connection, params) {
1✔
239
  if (!this.cfg.relay.all) return next()
2!
240

2✔
241
  connection.loginfo(this, `confirming recipient ${params[0]}`)
2✔
242
  connection.relaying = true
2✔
243
  next(OK)
2✔
244
}
2✔
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