Skip to content

Commit 8bd3118

Browse files
Merge pull request #16709 from rabbitmq/address-v-sec-issues
Refactor HTTP API responses
2 parents c0cf403 + 208afc1 commit 8bd3118

21 files changed

Lines changed: 375 additions & 88 deletions

File tree

deps/oauth2_client/src/jwt_helper.erl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ decode(Token) ->
1818
{error, {invalid_token, Type, Err, Stacktrace}}
1919
end.
2020

21-
get_expiration_time(#{<<"exp">> := Exp}) when is_integer(Exp) -> {ok, Exp};
21+
get_expiration_time(#{<<"exp">> := Exp}) when is_number(Exp) -> {ok, trunc(Exp)};
22+
get_expiration_time(#{<<"exp">> := _}) -> {error, invalid_exp_field};
2223
get_expiration_time(#{}) -> {error, missing_exp_field}.

deps/rabbit/src/rabbit_auth_backend_internal.erl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,26 @@ internal_check_user_login(Username, Fun) ->
137137
_ -> Refused
138138
end;
139139
{error, not_found} ->
140+
%% Equalise timing regardless of whether the username exists to
141+
%% prevent username enumeration via a timing side-channel.
142+
%% The result is always discarded.
143+
_ = Fun(dummy_sentinel_user()),
140144
Refused
141145
end.
142146

147+
%% A sentinel internal_user whose password hash will never match any real
148+
%% cleartext. Used solely to perform a dummy hash computation on the
149+
%% not-found path, equalising timing with the found-but-wrong-password path.
150+
%% Layout: 4 bytes zeroed salt + 32 bytes zeroed SHA-256 payload.
151+
-define(DUMMY_PASSWORD_HASH, <<0:32, 0:256>>).
152+
153+
dummy_sentinel_user() ->
154+
HashingMod = rabbit_password:hashing_mod(),
155+
internal_user:set_password_hash(
156+
internal_user:new({hashing_algorithm, HashingMod}),
157+
?DUMMY_PASSWORD_HASH,
158+
HashingMod).
159+
143160
check_vhost_access(#auth_user{username = Username}, VHostPath, _AuthzData) ->
144161
rabbit_db_user:get_user_permissions(Username, VHostPath) =/= undefined.
145162

deps/rabbit/test/unit_access_control_SUITE.erl

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ groups() ->
3232
{sequential_tests, [], [
3333
login_with_credentials_but_no_password,
3434
login_of_passwordless_user,
35+
login_of_nonexistent_user,
3536
set_tags_for_passwordless_user,
3637
change_password,
3738
auth_backend_internal_expand_topic_permission
@@ -188,6 +189,26 @@ login_of_passwordless_user1(_Config) ->
188189

189190
passed.
190191

192+
login_of_nonexistent_user(Config) ->
193+
passed = rabbit_ct_broker_helpers:rpc(Config, 0,
194+
?MODULE, login_of_nonexistent_user1, [Config]).
195+
196+
login_of_nonexistent_user1(_Config) ->
197+
Username = <<"login_of_nonexistent_user-user">>,
198+
Password = <<"login_of_nonexistent_user-password">>,
199+
%% Ensure the user does not exist before the test.
200+
case rabbit_auth_backend_internal:lookup_user(Username) of
201+
{ok, _} -> rabbit_auth_backend_internal:delete_user(Username, <<"acting-user">>);
202+
_ -> ok
203+
end,
204+
%% Authentication of a non-existent user must return {refused, ...} and
205+
%% must not crash or return an error tuple.
206+
?assertMatch(
207+
{refused, _Message, [Username]},
208+
rabbit_auth_backend_internal:user_login_authentication(
209+
Username, [{password, Password}])),
210+
passed.
211+
191212

192213
set_tags_for_passwordless_user(Config) ->
193214
passed = rabbit_ct_broker_helpers:rpc(Config, 0,

deps/rabbitmq_auth_backend_oauth2/src/rabbit_auth_backend_oauth2.erl

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,6 @@ update_state(AuthUser, NewToken) ->
160160
-spec expiry_timestamp(rabbit_types:auth_user()) -> integer() | never.
161161
expiry_timestamp(#auth_user{impl = DecodedTokenFun}) ->
162162
case DecodedTokenFun() of
163-
%% "exp" may be a fractional NumericDate (RFC 7519); accept floats too.
164163
#{<<"exp">> := Exp} when is_number(Exp) ->
165164
trunc(Exp);
166165
_ ->
@@ -236,14 +235,15 @@ ensure_same_username(PreferredUsernameClaims, CurrentDecodedToken, NewDecodedTok
236235
_ -> {error, mismatch_username_after_token_refresh}
237236
end.
238237

239-
%% "exp" may be a fractional NumericDate (RFC 7519); accept floats too.
240238
validate_token_expiry(#{<<"exp">> := Exp}) when is_number(Exp) ->
241-
ExpSeconds = trunc(Exp),
242239
Now = os:system_time(seconds),
243-
case ExpSeconds =< Now of
244-
true -> {error, rabbit_misc:format("Provided JWT token has expired at timestamp ~tp (validated at ~tp)", [ExpSeconds, Now])};
240+
ExpTs = trunc(Exp),
241+
case ExpTs =< Now of
242+
true -> {error, rabbit_misc:format("Provided JWT token has expired at timestamp ~tp (validated at ~tp)", [ExpTs, Now])};
245243
false -> ok
246244
end;
245+
validate_token_expiry(#{<<"exp">> := _}) ->
246+
{error, "Provided JWT token has an invalid exp claim: value is not a number"};
247247
validate_token_expiry(#{}) -> ok.
248248

249249
-spec check_token(raw_jwt_token(), {resource_server(), internal_oauth_provider()}) ->

deps/rabbitmq_auth_backend_oauth2/test/unit_SUITE.erl

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ all() ->
4040
test_restricted_vhost_access_with_a_valid_token,
4141
test_insufficient_permissions_in_a_valid_token,
4242
test_token_expiration,
43-
test_token_expiration_with_float_exp,
43+
test_token_expiry_with_float_exp,
44+
test_token_expiry_with_non_numeric_exp,
4445
test_invalid_signature,
4546
test_incorrect_kid,
4647
normalize_token_scope_using_multiple_scopes_key,
@@ -1242,6 +1243,40 @@ test_token_expiration(_) ->
12421243
?assertMatch({refused, _, _},
12431244
user_login_authentication(Username, [{password, Token}])).
12441245

1246+
test_token_expiry_with_float_exp(_) ->
1247+
Username = <<"username">>,
1248+
set_env(resource_server_id, <<"rabbitmq">>),
1249+
1250+
%% A valid, future float exp must be accepted and expiry_timestamp must
1251+
%% return the truncated integer — not the atom 'never'.
1252+
FutureFloatExp = float(os:system_time(seconds) + 600),
1253+
FutureToken = (?UTIL_MOD:token_with_sub(?UTIL_MOD:expirable_token(), Username))
1254+
#{<<"exp">> := FutureFloatExp},
1255+
{ok, #auth_user{username = Username} = User} =
1256+
user_login_authentication(Username, [{password, FutureToken}]),
1257+
?assertEqual(trunc(FutureFloatExp), rabbit_auth_backend_oauth2:expiry_timestamp(User)),
1258+
1259+
%% An already-expired float exp must be refused, not silently accepted.
1260+
PastFloatExp = float(os:system_time(seconds) - 10),
1261+
ExpiredToken = (?UTIL_MOD:token_with_sub(?UTIL_MOD:expirable_token(), Username))
1262+
#{<<"exp">> := PastFloatExp},
1263+
?assertMatch({refused, _, _},
1264+
user_login_authentication(Username, [{password, ExpiredToken}])).
1265+
1266+
test_token_expiry_with_non_numeric_exp(_) ->
1267+
Username = <<"username">>,
1268+
set_env(resource_server_id, <<"rabbitmq">>),
1269+
1270+
%% A token whose exp claim is not a number (e.g. a string) must be refused,
1271+
%% not silently accepted because the is_number guard falls through to the
1272+
%% no-exp-field catch-all clause.
1273+
lists:foreach(fun(NonNumericExp) ->
1274+
InvalidToken = (?UTIL_MOD:token_with_sub(?UTIL_MOD:expirable_token(), Username))
1275+
#{<<"exp">> := NonNumericExp},
1276+
?assertMatch({refused, _, _},
1277+
user_login_authentication(Username, [{password, InvalidToken}]))
1278+
end, [<<"1700000300">>, true, false, null]).
1279+
12451280
test_incorrect_kid(_) ->
12461281
AltKid = <<"other-token-key">>,
12471282
Username = <<"username">>,

deps/rabbitmq_management/priv/www/js/dispatcher.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,13 @@ dispatcher_add(function(sammy) {
308308
});
309309
let datamodel = {
310310
'limits': '/vhost-limits',
311-
'user_limits': '/user-limits',
312311
'vhosts': '/vhosts'
313312
}
314313
if (ac.isAdministratorUser()) {
314+
datamodel['user_limits'] = '/user-limits'
315315
datamodel['users'] = '/users'
316+
} else {
317+
datamodel['user_limits'] = '/user-limits/' + esc(user_name)
316318
}
317319
path('#/limits', datamodel, 'limits');
318320

deps/rabbitmq_management/src/rabbit_mgmt_login.erl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ handleAccessToken(Req0, AccessToken, State) ->
123123
redirect_to_home_with_cookie(CookieName, CookieValue, Req=#{scheme := Scheme}, State) ->
124124
CookieSettings0 = #{
125125
http_only => true,
126-
path => rabbit_mgmt_util:get_oauth2_bootstrap_cookie_path(),
126+
path => rabbit_mgmt_util:prefixed_path(?OAUTH2_BOOTSTRAP_PATH),
127127
max_age => 30,
128128
same_site => strict
129129
},

deps/rabbitmq_management/src/rabbit_mgmt_oauth_bootstrap.erl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ get_auth_mechanism(Req) ->
7676
<<"">>, Req, #{
7777
max_age => 0,
7878
http_only => true,
79-
path => rabbit_mgmt_util:get_oauth2_bootstrap_cookie_path(),
79+
path => iolist_to_binary([rabbit_mgmt_util:get_path_prefix(),
80+
?OAUTH2_BOOTSTRAP_PATH]),
8081
same_site => strict
8182
}),
8283
Auth
@@ -128,7 +129,7 @@ set_token_auth(AuthSettings, Req0) ->
128129
?OAUTH2_ACCESS_TOKEN, <<"">>, Req0, #{
129130
max_age => 0,
130131
http_only => true,
131-
path => rabbit_mgmt_util:get_oauth2_bootstrap_cookie_path(),
132+
path => rabbit_mgmt_util:prefixed_path(?OAUTH2_BOOTSTRAP_PATH),
132133
same_site => strict
133134
}),
134135
["set_token_auth(", rabbit_json:encode(Token), ");"]

deps/rabbitmq_management/src/rabbit_mgmt_util.erl

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
-export([direct_request/6]).
4747
-export([qs_val/2]).
4848
-export([get_path_prefix/0]).
49-
-export([get_oauth2_bootstrap_cookie_path/0]).
49+
-export([prefixed_path/1]).
5050
-export([catch_no_such_user_or_vhost/2]).
5151
-export([method_not_allowed/3]).
5252

@@ -467,10 +467,11 @@ fixup_prefix(EnvPrefix) when is_list(EnvPrefix) ->
467467
fixup_prefix(EnvPrefix) when is_binary(EnvPrefix) ->
468468
fixup_prefix(rabbit_data_coercion:to_list(EnvPrefix)).
469469

470-
%% Path for the short-lived /login cookies. It must carry the HTTP API path
471-
%% prefix, or the browser will not send them to the prefixed bootstrap.js.
472-
get_oauth2_bootstrap_cookie_path() ->
473-
iolist_to_binary([get_path_prefix(), ?OAUTH2_BOOTSTRAP_PATH]).
470+
%% Prepends the configured path prefix to Path, returning a binary.
471+
%% Used wherever a cookie or redirect path must reflect the management
472+
%% plugin's configured path_prefix.
473+
prefixed_path(Path) ->
474+
iolist_to_binary([get_path_prefix(), Path]).
474475

475476
%% XXX sort_list_and_paginate/2 is a more proper name for this function, keeping it
476477
%% with this name for backwards compatibility

deps/rabbitmq_management/src/rabbit_mgmt_wm_user_limits.erl

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ allowed_methods(ReqData, Context) ->
2424
{[<<"GET">>, <<"OPTIONS">>], ReqData, Context}.
2525

2626
is_authorized(ReqData, Context) ->
27-
rabbit_mgmt_util:is_authorized_vhost_visible(ReqData, Context).
27+
case user(ReqData) of
28+
none ->
29+
%% listing all users' limits is an administrator-only operation
30+
rabbit_mgmt_util:is_authorized_admin(ReqData, Context);
31+
Username ->
32+
%% reading a specific user's limits: administrator/monitor or own account
33+
rabbit_mgmt_util:is_authorized_user(ReqData, Context, [{user, Username}])
34+
end.
2835

2936
content_types_provided(ReqData, Context) ->
3037
{[{<<"application/json">>, to_json}], ReqData, Context}.

0 commit comments

Comments
 (0)