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

haraka / Haraka / 26968099867

04 Jun 2026 05:24PM UTC coverage: 72.176% (-0.4%) from 72.622%
26968099867

Pull #3582

github

web-flow
Merge b89cdc8bd into b6dac60aa
Pull Request #3582: Release 3.3.0

1678 of 2207 branches covered (76.03%)

1 of 1 new or added line in 1 file covered. (100.0%)

91 existing lines in 6 files now uncovered.

7611 of 10545 relevant lines covered (72.18%)

25.96 hits per line

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

60.92
/plugins/auth/auth_base.js
1
// Base authentication plugin.
6✔
2
// This cannot be used on its own. You need to inherit from it.
6✔
3
// See plugins/auth/flat_file.js for an example.
6✔
4

6✔
5
// Note: You can disable setting `connection.notes.auth_passwd` by `plugin.blankout_password = true`
6✔
6

6✔
7
const crypto = require('node:crypto')
6✔
8

6✔
9
const tlds = require('haraka-tld')
6✔
10
const utils = require('haraka-utils')
6✔
11

6✔
12
const AUTH_COMMAND = 'AUTH'
6✔
13
const AUTH_METHOD_CRAM_MD5 = 'CRAM-MD5'
6✔
14
const AUTH_METHOD_PLAIN = 'PLAIN'
6✔
15
const AUTH_METHOD_LOGIN = 'LOGIN'
6✔
16
const LOGIN_STRING1 = 'VXNlcm5hbWU6' //Username: base64 coded
6✔
17
const LOGIN_STRING2 = 'UGFzc3dvcmQ6' //Password: base64 coded
6✔
18

6✔
19
exports.hook_capabilities = (next, connection) => {
6✔
20
    // Don't offer AUTH capabilities unless session is encrypted
×
21
    if (!connection.tls.enabled) return next()
×
22

✔
23
    const methods = ['PLAIN', 'LOGIN', 'CRAM-MD5']
×
24
    connection.capabilities.push(`AUTH ${methods.join(' ')}`)
×
25
    connection.notes.allowed_auth_methods = methods
×
26
    next()
×
27
}
×
28

×
29
// Override this at a minimum. Run cb(passwd) to provide a password.
6✔
30
exports.get_plain_passwd = (user, connection, cb) => cb()
6✔
31

×
32
exports.hook_unrecognized_command = function (next, connection, params) {
6✔
33
    if (params[0].toUpperCase() === AUTH_COMMAND && params[1]) {
4✔
34
        return this.select_auth_method(next, connection, params.slice(1).join(' '))
4✔
35
    }
2✔
36
    if (!connection.notes.authenticating) return next()
2!
37

×
38
    const am = connection.notes.auth_method
4✔
39
    if (am === AUTH_METHOD_CRAM_MD5 && connection.notes.auth_ticket) {
2!
40
        return this.auth_cram_md5(next, connection, params)
4!
41
    }
2✔
42
    if (am === AUTH_METHOD_LOGIN) {
4!
43
        return this.auth_login(next, connection, params)
✔
44
    }
×
45
    if (am === AUTH_METHOD_PLAIN) {
✔
46
        return this.auth_plain(next, connection, params)
×
47
    }
×
48
    next()
×
49
}
×
50

×
51
exports.check_plain_passwd = function (connection, user, passwd, cb) {
6✔
52
    function callback(plain_pw) {
✔
UNCOV
53
        cb(plain_pw === null ? false : plain_pw === passwd)
✔
UNCOV
54
    }
×
55
    if (this.get_plain_passwd.length == 2) {
×
56
        this.get_plain_passwd(user, callback)
✔
57
    } else if (this.get_plain_passwd.length == 3) {
✔
58
        this.get_plain_passwd(user, connection, callback)
×
59
    } else {
×
60
        throw 'Invalid number of arguments for get_plain_passwd'
×
61
    }
×
62
}
×
63

×
64
exports.check_cram_md5_passwd = function (connection, user, passwd, cb) {
6✔
65
    function callback(plain_pw) {
2✔
66
        if (plain_pw == null) return cb(false)
2!
67

×
68
        const hmac = crypto.createHmac('md5', plain_pw.toString())
2✔
69
        hmac.update(connection.notes.auth_ticket)
2✔
70

2✔
71
        if (hmac.digest('hex') === passwd) return cb(true)
2✔
72

1✔
73
        cb(false)
1✔
74
    }
1✔
75
    if (this.get_plain_passwd.length == 2) {
2✔
76
        this.get_plain_passwd(user, callback)
2!
77
    } else if (this.get_plain_passwd.length == 3) {
2✔
78
        this.get_plain_passwd(user, connection, callback)
2✔
79
    } else {
2✔
80
        throw 'Invalid number of arguments for get_plain_passwd'
2!
81
    }
×
82
}
×
83

×
84
exports.check_user = function (next, connection, credentials, method) {
6✔
85
    const plugin = this
2✔
86
    connection.notes.authenticating = false
2✔
87
    if (!(credentials[0] && credentials[1])) {
2✔
88
        connection.respond(504, 'Invalid AUTH string', () => {
2!
89
            connection.reset_transaction(() => next(OK))
✔
UNCOV
90
        })
×
91
        return
×
92
    }
×
93

×
94
    // valid: (true|false)
2✔
95
    // opts: ({ message, code }|String)
2✔
96
    function passwd_ok(valid, opts) {
2✔
97
        const status_code = (typeof opts === 'object' && opts.code) || (valid ? 235 : 535)
2!
98
        const status_message =
2✔
99
            (typeof opts === 'object' ? opts.message : opts) ||
2!
100
            (valid ? '2.7.0 Authentication successful' : '5.7.8 Authentication failed')
2✔
101

1✔
102
        // The AUTH username is attacker-controlled (base64-decoded). Strip
2✔
103
        // control chars before it is stored in notes or emitted into the
2✔
104
        // Authentication-Results header (header injection).
2✔
105
        // eslint-disable-next-line no-control-regex
2✔
106
        const safe_user = String(credentials[0] ?? '').replace(/[\x00-\x1f\x7f]/g, '')
2!
107

2✔
108
        if (valid) {
2✔
109
            connection.relaying = true
2✔
110
            connection.results.add({ name: 'relay' }, { pass: plugin.name })
1✔
111

1✔
112
            connection.results.add(
1✔
113
                { name: 'auth' },
1✔
114
                {
1✔
115
                    pass: plugin.name,
1✔
116
                    method,
1✔
117
                    user: safe_user,
1✔
118
                },
1✔
119
            )
1✔
120

1✔
121
            connection.respond(status_code, status_message, () => {
1✔
122
                connection.authheader = '(authenticated bits=0)\n'
1✔
123
                connection.auth_results(`auth=pass (${method.toLowerCase()})`)
1✔
124
                connection.notes.auth_user = safe_user
1✔
125
                if (!plugin.blankout_password) connection.notes.auth_passwd = credentials[1]
1✔
126
                next(OK)
1✔
127
            })
1✔
128
            return
1✔
129
        }
1✔
130

1✔
131
        if (!connection.notes.auth_fails) connection.notes.auth_fails = 0
1✔
132

1✔
133
        connection.notes.auth_fails++
1✔
134
        connection.results.add({ name: 'auth' }, { fail: `${plugin.name}/${method}` })
1✔
135

1✔
136
        let delay = Math.pow(2, connection.notes.auth_fails - 1)
1✔
137
        if (plugin.timeout && delay >= plugin.timeout) {
1✔
138
            delay = plugin.timeout - 1
2!
139
        }
×
140
        connection.lognotice(plugin, `delaying for ${delay} seconds`)
2✔
141
        // here we include the username, as shown in RFC 5451 example
1✔
142
        connection.auth_results(`auth=fail (${method.toLowerCase()}) smtp.auth=${safe_user}`)
1✔
143
        setTimeout(() => {
1✔
144
            connection.respond(status_code, status_message, () => {
1✔
145
                connection.reset_transaction(() => next(OK))
1✔
146
            })
1✔
147
        }, delay * 1000)
1✔
148
    }
1✔
149

1✔
150
    if (method === AUTH_METHOD_PLAIN || method === AUTH_METHOD_LOGIN) {
2✔
151
        plugin.check_plain_passwd(connection, credentials[0], credentials[1], passwd_ok)
2!
152
    } else if (method === AUTH_METHOD_CRAM_MD5) {
2!
153
        plugin.check_cram_md5_passwd(connection, credentials[0], credentials[1], passwd_ok)
2✔
154
    }
2✔
155
}
2✔
156

2✔
157
exports.select_auth_method = function (next, connection, method) {
6✔
158
    const split = method.split(/\s+/)
2✔
159
    method = split.shift().toUpperCase()
2✔
160
    if (!connection.notes.allowed_auth_methods) return next()
2!
161
    if (!connection.notes.allowed_auth_methods.includes(method)) return next()
2!
162

✔
163
    if (connection.notes.authenticating) return next(DENYDISCONNECT, 'bad protocol')
2!
164

×
165
    connection.notes.authenticating = true
2✔
166
    connection.notes.auth_method = method
2✔
167

2✔
168
    if (method === AUTH_METHOD_PLAIN) return this.auth_plain(next, connection, split)
2!
169
    if (method === AUTH_METHOD_LOGIN) return this.auth_login(next, connection, split)
2!
170
    if (method === AUTH_METHOD_CRAM_MD5) return this.auth_cram_md5(next, connection)
2!
171
}
2✔
172

2✔
173
exports.auth_plain = function (next, connection, params) {
6✔
174
    // one parameter given on line, either:
×
175
    //    AUTH PLAIN <param> or
×
176
    //    AUTH PLAIN\n
×
177
    //...
×
178
    //    <param>
×
179
    if (params[0]) {
×
180
        const credentials = utils.unbase64(params[0]).split(/\0/)
✔
181
        credentials.shift() // Discard authid
×
182
        this.check_user(next, connection, credentials, AUTH_METHOD_PLAIN)
×
183
        return
×
184
    }
×
185

×
186
    if (connection.notes.auth_plain_asked_login) {
✔
187
        return next(DENYDISCONNECT, 'bad protocol')
×
188
    }
×
189

×
190
    connection.respond(334, ' ', () => {
✔
191
        connection.notes.auth_plain_asked_login = true
✔
UNCOV
192
        next(OK)
×
UNCOV
193
    })
×
UNCOV
194
}
×
UNCOV
195

×
196
exports.auth_login = function (next, connection, params) {
6✔
197
    if (
×
198
        (!connection.notes.auth_login_asked_login && params[0]) ||
✔
199
        (connection.notes.auth_login_asked_login && !connection.notes.auth_login_userlogin)
✔
200
    ) {
×
201
        if (!params[0]) return next(DENYDISCONNECT, 'bad protocol')
×
202

×
203
        const login = utils.unbase64(params[0])
×
204
        connection.respond(334, LOGIN_STRING2, () => {
×
205
            connection.notes.auth_login_userlogin = login
✔
UNCOV
206
            connection.notes.auth_login_asked_login = true
×
UNCOV
207
            next(OK)
×
UNCOV
208
        })
×
209
        return
×
210
    }
×
211

×
212
    if (connection.notes.auth_login_userlogin) {
✔
213
        const credentials = [connection.notes.auth_login_userlogin, utils.unbase64(params[0])]
✔
214

×
215
        connection.notes.auth_login_userlogin = null
×
216
        connection.notes.auth_login_asked_login = false
×
217

×
218
        return this.check_user(next, connection, credentials, AUTH_METHOD_LOGIN)
×
219
    }
×
220

×
221
    connection.respond(334, LOGIN_STRING1, () => {
✔
222
        connection.notes.auth_login_asked_login = true
✔
UNCOV
223
        next(OK)
×
UNCOV
224
    })
×
UNCOV
225
}
×
UNCOV
226

×
227
exports.auth_cram_md5 = function (next, connection, params) {
6✔
228
    if (params) {
4✔
229
        const credentials = utils.unbase64(params[0]).split(' ')
4✔
230
        return this.check_user(next, connection, credentials, AUTH_METHOD_CRAM_MD5)
2✔
231
    }
2✔
232

2✔
233
    const ticket = `<${this.hexi(Math.floor(Math.random() * 1000000))}.${this.hexi(Date.now())}@${connection.local.host}>`
2✔
234

2✔
235
    connection.loginfo(this, `ticket: ${ticket}`)
2✔
236
    connection.respond(334, utils.base64(ticket), () => {
2✔
237
        connection.notes.auth_ticket = ticket
2✔
238
        next(OK)
2✔
239
    })
2✔
240
}
2✔
241

2✔
242
exports.hexi = (number) => String(Math.abs(parseInt(number)).toString(16))
6✔
243

4✔
244
exports.constrain_sender = function (next, connection, params) {
6✔
245
    if (this?.cfg?.main?.constrain_sender === false) return next()
3!
246

×
247
    const au = connection.results.get('auth')?.user
3✔
248
    if (!au) return next()
3✔
249

2!
250
    const ad = /@/.test(au) ? au.split('@').pop() : null
3!
251
    const ed = params[0].host
3✔
252

3✔
253
    if (!ad || !ed) return next()
3!
254

1✔
255
    const auth_od = tlds.get_organizational_domain(ad)
3!
256
    const envelope_od = tlds.get_organizational_domain(ed)
×
257

×
258
    if (auth_od === envelope_od) return next()
×
259

✔
260
    next(DENY, `Envelope domain '${envelope_od}' doesn't match AUTH domain '${auth_od}'`)
×
261
}
×
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