Skip to content

Commit 3caf5f2

Browse files
committed
🔒 Verify SASL authenticators are done
The protocol client is responsible for raising an error if the command completes successfully but "done?" returns false.
1 parent 6cd6d87 commit 3caf5f2

10 files changed

+134
-12
lines changed

lib/net/imap.rb

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,7 @@ def logout
10881088
def logout!
10891089
logout unless disconnected?
10901090
rescue => ex
1091-
warn "%s during <Net::IMAP %s:%s>logout!: %s" % [
1091+
warn "%s during <Net::IMAP %s:%s> logout!: %s" % [
10921092
ex.class, host, port, ex
10931093
]
10941094
ensure
@@ -1238,17 +1238,20 @@ def authenticate(mechanism, *creds, sasl_ir: true, **props, &callback)
12381238
SASL.initial_response?(authenticator)
12391239
cmdargs << [authenticator.process(nil)].pack("m0")
12401240
end
1241-
send_command(*cmdargs) do |resp|
1241+
result = send_command(*cmdargs) do |resp|
12421242
if resp.instance_of?(ContinuationRequest)
12431243
challenge = resp.data.text.unpack1("m")
12441244
response = authenticator.process(challenge)
12451245
response = [response].pack("m0")
12461246
put_string(response + CRLF)
12471247
end
12481248
end
1249-
.tap { @capabilities = capabilities_from_resp_code _1 }
1250-
# NOTE: If any Net::IMAP::SASL mechanism ever supports security layer
1251-
# negotiation, capabilities sent during the "OK" response MUST be ignored.
1249+
unless SASL.done?(authenticator)
1250+
logout!
1251+
raise SASL::AuthenticationFailed, "authentication ended prematurely"
1252+
end
1253+
@capabilities = capabilities_from_resp_code result
1254+
result
12521255
end
12531256

12541257
# Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3]

lib/net/imap/sasl.rb

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,31 @@ class IMAP
7777
# <em>Using a deprecated mechanism will print a warning.</em>
7878
#
7979
module SASL
80+
# Exception class for any client error detected during the authentication
81+
# exchange.
82+
#
83+
# When the _server_ reports an authentication failure, it will respond
84+
# with a protocol specific error instead, e.g: +BAD+ or +NO+ in IMAP.
85+
#
86+
# When the client encounters any error, it *must* consider the
87+
# authentication exchange to be unsuccessful and it might need to drop the
88+
# connection. For example, if the server reports that the authentication
89+
# exchange was successful or the protocol does not allow additional
90+
# authentication attempts.
91+
Error = Class.new(StandardError)
92+
93+
# Indicates an authentication exchange that will be or has been canceled
94+
# by the client, not due to any error or failure during processing.
95+
AuthenticationCanceled = Class.new(Error)
96+
97+
# Indicates an error when processing a server challenge, e.g: an invalid
98+
# or unparsable challenge. An underlying exception may be available as
99+
# the exception's #cause.
100+
AuthenticationError = Class.new(Error)
101+
102+
# Indicates that authentication cannot proceed because one of the server's
103+
# messages has not passed integrity checks.
104+
AuthenticationFailed = Class.new(Error)
80105

81106
# autoloading to avoid loading all of the regexps when they aren't used.
82107
sasl_stringprep_rb = File.expand_path("sasl/stringprep", __dir__)
@@ -118,13 +143,24 @@ def saslprep(string, **opts)
118143
Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts)
119144
end
120145

121-
# Returns whether the authenticator is client-first and supports sending
122-
# an "initial response".
146+
# Returns whether +authenticator+ is client-first and supports sending an
147+
# "initial response".
123148
def initial_response?(authenticator)
124149
authenticator.respond_to?(:initial_response?) &&
125150
authenticator.initial_response?
126151
end
127152

153+
# Returns whether +authenticator+ considers the authentication exchange to
154+
# be complete.
155+
#
156+
# The authentication should not succeed if this returns false, but
157+
# returning true does *not* indicate success. Authentication succeeds
158+
# when this method returns true and the server responds with a
159+
# protocol-specific success.
160+
def done?(authenticator)
161+
!authenticator.respond_to?(:done?) || authenticator.done?
162+
end
163+
128164
end
129165
end
130166
end

lib/net/imap/sasl/anonymous_authenticator.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def initialize(anon_msg = nil, anonymous_message: nil, **)
4040
raise ArgumentError,
4141
"anonymous_message is too long. (%d codepoints)" % [size]
4242
end
43+
@done = false
4344
end
4445

4546
# :call-seq:
@@ -51,8 +52,16 @@ def initial_response?; true end
5152
# Returns #anonymous_message.
5253
def process(_server_challenge_string)
5354
anonymous_message
55+
ensure
56+
@done = true
5457
end
5558

59+
# Returns true when the initial client response was sent.
60+
#
61+
# The authentication should not succeed unless this returns true, but it
62+
# does *not* indicate success.
63+
def done?; @done end
64+
5665
end
5766
end
5867
end

lib/net/imap/sasl/cram_md5_authenticator.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,18 @@ def initialize(user, password, warn_deprecation: true, **_ignored)
2121
require "digest/md5"
2222
@user = user
2323
@password = password
24+
@done = false
2425
end
2526

2627
def process(challenge)
2728
digest = hmac_md5(challenge, @password)
2829
return @user + " " + digest
30+
ensure
31+
@done = true
2932
end
3033

34+
def done?; @done end
35+
3136
private
3237

3338
def hmac_md5(text, key)

lib/net/imap/sasl/digest_md5_authenticator.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
class Net::IMAP::SASL::DigestMD5Authenticator
1212
STAGE_ONE = :stage_one
1313
STAGE_TWO = :stage_two
14-
private_constant :STAGE_ONE, :STAGE_TWO
14+
STAGE_DONE = :stage_done
15+
private_constant :STAGE_ONE, :STAGE_TWO, :STAGE_DONE
1516

1617
# Authentication identity: the identity that matches the #password.
1718
#
@@ -126,7 +127,7 @@ def process(challenge)
126127

127128
return response.keys.map {|key| qdval(key.to_s, response[key]) }.join(',')
128129
when STAGE_TWO
129-
@stage = nil
130+
@stage = STAGE_DONE
130131
# if at the second stage, return an empty string
131132
if challenge =~ /rspauth=/
132133
return ''
@@ -138,6 +139,8 @@ def process(challenge)
138139
end
139140
end
140141

142+
def done?; @stage == STAGE_DONE end
143+
141144
private
142145

143146
def nc(nonce)

lib/net/imap/sasl/external_authenticator.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def initialize(authzid: nil, **)
3434
if @authzid&.match?(/\u0000/u) # also validates UTF8 encoding
3535
raise ArgumentError, "contains NULL"
3636
end
37+
@done = false
3738
end
3839

3940
# :call-seq:
@@ -45,8 +46,16 @@ def initial_response?; true end
4546
# Returns #authzid, or an empty string if there is no authzid.
4647
def process(_)
4748
authzid || ""
49+
ensure
50+
@done = true
4851
end
4952

53+
# Returns true when the initial client response was sent.
54+
#
55+
# The authentication should not succeed unless this returns true, but it
56+
# does *not* indicate success.
57+
def done?; @done end
58+
5059
end
5160
end
5261
end

lib/net/imap/sasl/login_authenticator.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
class Net::IMAP::SASL::LoginAuthenticator
2121
STATE_USER = :USER
2222
STATE_PASSWORD = :PASSWORD
23-
private_constant :STATE_USER, :STATE_PASSWORD
23+
STATE_DONE = :DONE
24+
private_constant :STATE_USER, :STATE_PASSWORD, :STATE_DONE
2425

2526
def initialize(user, password, warn_deprecation: true, **_ignored)
2627
if warn_deprecation
@@ -37,7 +38,12 @@ def process(data)
3738
@state = STATE_PASSWORD
3839
return @user
3940
when STATE_PASSWORD
41+
@state = STATE_DONE
4042
return @password
43+
when STATE_DONE
44+
raise ResponseParseError, data
4145
end
4246
end
47+
48+
def done?; @state == STATE_DONE end
4349
end

lib/net/imap/sasl/plain_authenticator.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def initialize(user = nil, pass = nil,
6161
@username = username
6262
@password = password
6363
@authzid = authzid
64+
@done = false
6465
end
6566

6667
# :call-seq:
@@ -72,6 +73,14 @@ def initial_response?; true end
7273
# Responds with the client's credentials.
7374
def process(data)
7475
return "#@authzid\0#@username\0#@password"
76+
ensure
77+
@done = true
7578
end
7679

80+
# Returns true when the initial client response was sent.
81+
#
82+
# The authentication should not succeed unless this returns true, but it
83+
# does *not* indicate success.
84+
def done?; @done end
85+
7786
end

lib/net/imap/sasl/xoauth2_authenticator.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def initialize(user = nil, token = nil, username: nil, oauth2_token: nil, **)
5656
raise ArgumentError, "conflicting values for username"
5757
[oauth2_token, token].compact.count == 1 or
5858
raise ArgumentError, "conflicting values for oauth2_token"
59+
@done = false
5960
end
6061

6162
# :call-seq:
@@ -68,8 +69,16 @@ def initial_response?; true end
6869
# with the +oauth2_token+.
6970
def process(_data)
7071
build_oauth2_string(@username, @oauth2_token)
72+
ensure
73+
@done = true
7174
end
7275

76+
# Returns true when the initial client response was sent.
77+
#
78+
# The authentication should not succeed unless this returns true, but it
79+
# does *not* indicate success.
80+
def done?; @done end
81+
7382
private
7483

7584
def build_oauth2_string(username, oauth2_token)

test/net/imap/test_imap.rb

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,18 +911,51 @@ def test_id
911911
].pack("m0")
912912
)
913913
state.commands << {continuation: response_b64}
914+
response_b64 = cmd.request_continuation(["rspauth="].pack("m0"))
915+
state.commands << {continuation: response_b64}
914916
server.state.authenticate(server.config.user)
915917
cmd.done_ok
916918
end
917919
imap.authenticate("DIGEST-MD5", "test_user", "test-password",
918920
warn_deprecation: false)
919-
cmd, cont = 2.times.map { server.commands.pop }
921+
cmd, cont1, cont2 = 3.times.map { server.commands.pop }
920922
assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args]
921-
assert_match(%r{\A[a-z0-9+/]+=*\z}i, cont[:continuation].strip)
923+
assert_match(%r{\A[a-z0-9+/]+=*\z}i, cont1[:continuation].strip)
924+
assert_empty cont2[:continuation].strip
922925
assert_empty server.commands
923926
end
924927
end
925928

929+
test("#authenticate disconnects and raises SASL::AuthenticationFailed " \
930+
"when the server succeeds prematurely") do
931+
with_fake_server(
932+
preauth: false, cleartext_auth: true,
933+
sasl_ir: true, sasl_mechanisms: %i[DIGEST-MD5]
934+
) do |server, imap|
935+
server.on "AUTHENTICATE" do |cmd|
936+
response_b64 = cmd.request_continuation(
937+
[
938+
%w[
939+
realm="somerealm"
940+
nonce="OA6MG9tEQGm2hh"
941+
qop="auth"
942+
charset=utf-8
943+
algorithm=md5-sess
944+
].join(",")
945+
].pack("m0")
946+
)
947+
state.commands << {continuation: response_b64}
948+
server.state.authenticate(server.config.user)
949+
cmd.done_ok
950+
end
951+
assert_raise(Net::IMAP::SASL::AuthenticationFailed) do
952+
imap.authenticate("DIGEST-MD5", "test_user", "test-password",
953+
warn_deprecation: false)
954+
end
955+
assert imap.disconnected?
956+
end
957+
end
958+
926959
def test_uidplus_uid_expunge
927960
with_fake_server(select: "INBOX",
928961
extensions: %i[UIDPLUS]) do |server, imap|

0 commit comments

Comments
 (0)