Skip to content

Commit c3e252d

Browse files
Merge pull request #16946 from rabbitmq/RMQ-2785
[mgt] Require .json extension to upload definitions file
2 parents 8f23124 + 9f20bf7 commit c3e252d

16 files changed

Lines changed: 278 additions & 23 deletions

deps/rabbitmq_management/priv/schema/rabbitmq_management.schema

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,20 @@ end}.
487487
{mapping, "management.http.hide_allow_header", "rabbitmq_management.hide_http_allow_header",
488488
[{datatype, boolean}]}.
489489

490+
%% When enabled, multipart definition uploads to the HTTP API are only accepted
491+
%% when the uploaded file name has a .json extension.
492+
%%
493+
%% Files with any other extension, or with no extension at all, are rejected with HTTP 400.
494+
%%
495+
%% This setting applies to any client using the HTTP API, not just the management UI.
496+
%% Definitions posted with a Content-Type of application/json are not affected.
497+
%% Disabled by default to preserve backwards compatibility.
498+
%%
499+
%% {require_definition_json_extension, false},
500+
501+
{mapping, "management.definitions.require_json_extension", "rabbitmq_management.require_definition_json_extension",
502+
[{datatype, boolean}]}.
503+
490504
{mapping, "management.disable_basic_auth", "rabbitmq_management.disable_basic_auth",
491505
[{datatype, boolean}]}.
492506

deps/rabbitmq_management/priv/www/js/main.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11

2+
const API_ERROR_REASONS = {
3+
'failed_to_parse_json': 'Definitions file could not be parsed. Make sure it is valid JSON.',
4+
'unsupported_file_extension': '{filename}Only .json files are accepted for definitions import.',
5+
};
6+
7+
function format_error_response(response, reason) {
8+
var template = API_ERROR_REASONS[reason];
9+
if (typeof(template) != 'string') {
10+
return reason;
11+
}
12+
return template.replace(/\{(\w+)\}/g, function(_, key) {
13+
var val = response[key];
14+
return val !== undefined ? val + ': ' : '';
15+
});
16+
}
17+
218
$(document).ready(function() {
319
var url_string = window.location.href;
420
var url = new URL(url_string);
@@ -1560,7 +1576,7 @@ function check_bad_response(req, full_page_404, on404fun) {
15601576
} else if (on404fun && (typeof on404fun === 'function') && req.status == 404) {
15611577
on404fun(response);
15621578
} else {
1563-
show_popup('warn', fmt_escape_html(reason));
1579+
show_popup('warn', fmt_escape_html(format_error_response(response, reason)));
15641580
}
15651581
} else if (error == 'page_out_of_range') {
15661582
var seconds = 60;

deps/rabbitmq_management/priv/www/js/tmpl/overview.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@
347347
<td>
348348
<p>
349349
<label>Definitions file:</label><br/>
350-
<input type="file" name="file"/>
350+
<input type="file" name="file"<% if (overview.require_definition_json_extension) { %> accept=".json"<% } %>/>
351351
</p>
352352
</td>
353353
<td>

deps/rabbitmq_management/src/rabbit_mgmt_features.erl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
-export([is_op_policy_updating_disabled/0,
1111
is_qq_replica_operations_disabled/0,
12-
are_stats_enabled/0]).
12+
are_stats_enabled/0,
13+
is_definition_json_extension_required/0]).
1314

1415
is_qq_replica_operations_disabled() ->
1516
get_restriction([quorum_queue_replica_operations, disabled]).
@@ -28,6 +29,9 @@ are_stats_enabled() ->
2829
_ -> rabbit_mgmt_agent_config:is_metrics_collector_permitted()
2930
end.
3031

32+
is_definition_json_extension_required() ->
33+
application:get_env(rabbitmq_management, require_definition_json_extension, false).
34+
3135
%% Private
3236

3337
get_restriction(Path) ->

deps/rabbitmq_management/src/rabbit_mgmt_util.erl

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
is_authorized_vhost_visible_for_monitoring/2,
1919
is_authorized_global_parameters/2]).
2020
-export([user/1]).
21-
-export([bad_request/3, service_unavailable/3, bad_request_exception/4,
21+
-export([bad_request/3, bad_request/4, service_unavailable/3, bad_request_exception/4,
2222
internal_server_error/3, internal_server_error/4, precondition_failed/3,
2323
id/2, parse_bool/1, parse_int/1, redirect_to_home/3]).
2424
-export([with_decode/4, with_ids/4, not_found/3]).
@@ -698,6 +698,16 @@ a2b(B) -> B.
698698
bad_request(Reason, ReqData, Context) ->
699699
halt_response(400, bad_request, Reason, ReqData, Context).
700700

701+
%% Like bad_request/3 but merges ExtraFields into the JSON response body.
702+
%% Reason must be a binary or string.
703+
bad_request(Reason, ExtraFields, ReqData, Context) ->
704+
ReasonBin = rabbit_data_coercion:to_binary(Reason),
705+
Json = maps:merge(#{error => bad_request, reason => ReasonBin}, ExtraFields),
706+
ReqData1 = cowboy_req:reply(400,
707+
#{<<"content-type">> => <<"application/json">>},
708+
rabbit_json:encode(Json), ReqData),
709+
{stop, ReqData1, Context}.
710+
701711
service_unavailable(Reason, ReqData, Context) ->
702712
halt_response(503, service_unavailable, Reason, ReqData, Context).
703713

deps/rabbitmq_management/src/rabbit_mgmt_wm_definitions.erl

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
-export([content_types_accepted/2, allowed_methods/2, accept_json/2]).
1212
-export([accept_multipart/2]).
1313
-export([variances/2]).
14+
-export([has_json_extension/1]).
1415

1516
-include("rabbit_mgmt.hrl").
1617
-include_lib("rabbitmq_management_agent/include/rabbit_mgmt_records.hrl").
@@ -264,14 +265,25 @@ accept_multipart(ReqData0, Context) ->
264265
"Use the 'management.http.max_body_size' key in rabbitmq.conf to increase the limit if necessary",
265266
[BytesRead, LimitApplied]),
266267
rabbit_mgmt_util:bad_request("Exceeded HTTP request body size limit", ReqData0, Context);
267-
{Parts, ReqData} ->
268-
Redirect = get_part(<<"redirect">>, Parts),
269-
Payload = get_part(<<"file">>, Parts),
270-
Resp = {Res, _, _} = accept(Payload, ReqData, Context),
271-
case {Res, Redirect} of
272-
{true, unknown} -> {true, ReqData, Context};
273-
{true, _} -> {{true, Redirect}, ReqData, Context};
274-
_ -> Resp
268+
{Parts, Filename, ReqData} ->
269+
case is_acceptable_filename(Filename) of
270+
false ->
271+
ExtraFields = case Filename of
272+
unknown -> #{};
273+
_ -> #{filename => Filename}
274+
end,
275+
rabbit_mgmt_util:bad_request(<<"unsupported_file_extension">>,
276+
ExtraFields,
277+
ReqData, Context);
278+
true ->
279+
Redirect = get_part(<<"redirect">>, Parts),
280+
Payload = get_part(<<"file">>, Parts),
281+
Resp = {Res, _, _} = accept(Payload, ReqData, Context),
282+
case {Res, Redirect} of
283+
{true, unknown} -> {true, ReqData, Context};
284+
{true, _} -> {{true, Redirect}, ReqData, Context};
285+
_ -> Resp
286+
end
275287
end
276288
end.
277289

@@ -355,28 +367,42 @@ get_all_parts(Req, BodySizeLimit) ->
355367
N when is_integer(N), N > BodySizeLimit ->
356368
{error, http_body_limit_exceeded, BodySizeLimit, N};
357369
_ ->
358-
get_all_parts(Req, 0, BodySizeLimit, [])
370+
get_all_parts(Req, 0, BodySizeLimit, [], unknown)
359371
end.
360372

361-
get_all_parts(Req0, BodySize0, BodySizeLimit, Acc) ->
373+
get_all_parts(Req0, BodySize0, BodySizeLimit, Acc, FileFilename) ->
362374
case cowboy_req:read_part(Req0) of
363375
{done, Req1} ->
364-
{Acc, Req1};
376+
{Acc, FileFilename, Req1};
365377
{ok, Headers, Req1} ->
366378
%% Approximate maximum size of part headers.
367379
BodySize1 = BodySize0 + 2048,
368-
Name = case cow_multipart:form_data(Headers) of
369-
{data, N} -> N;
370-
{file, N, _, _} -> N
380+
{Name, Filename} = case cow_multipart:form_data(Headers) of
381+
{data, N} -> {N, unknown};
382+
{file, N, FN, _} -> {N, FN}
371383
end,
372384
case stream_part_body(Req1, BodySize1, BodySizeLimit, <<>>) of
373385
{ok, Body, BodySize, Req2} ->
374-
get_all_parts(Req2, BodySize, BodySizeLimit, [{Name, Body}|Acc]);
386+
%% Track the filename of the "file" field specifically.
387+
UpdatedFileFilename = case Name of
388+
<<"file">> -> Filename;
389+
_ -> FileFilename
390+
end,
391+
get_all_parts(Req2, BodySize, BodySizeLimit, [{Name, Body}|Acc], UpdatedFileFilename);
375392
{error, http_body_limit_exceeded, _, _} = Error ->
376393
Error
377394
end
378395
end.
379396

397+
is_acceptable_filename(Filename) ->
398+
not rabbit_mgmt_features:is_definition_json_extension_required()
399+
orelse has_json_extension(Filename).
400+
401+
has_json_extension(unknown) ->
402+
false;
403+
has_json_extension(Filename) ->
404+
filename:extension(string:lowercase(Filename)) =:= <<".json">>.
405+
380406
stream_part_body(Req0, BodySize, BodySizeLimit, Acc) ->
381407
%% Pass an explicit length to read_part_body so that Cowboy's internal
382408
%% buffering (default 8 MiB) respects the configured limit instead of

deps/rabbitmq_management/src/rabbit_mgmt_wm_overview.erl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ to_json(ReqData, Context = #context{user = User = #user{tags = Tags}}) ->
5555
{disable_stats, rabbit_mgmt_util:disable_stats(ReqData)},
5656
{default_queue_type, rabbit_queue_type:default_alias()},
5757
{is_op_policy_updating_enabled, not rabbit_mgmt_features:is_op_policy_updating_disabled()},
58-
{enable_queue_totals, rabbit_mgmt_util:enable_queue_totals(ReqData)}],
58+
{enable_queue_totals, rabbit_mgmt_util:enable_queue_totals(ReqData)},
59+
{require_definition_json_extension,
60+
rabbit_mgmt_features:is_definition_json_extension_required()}],
5961
try
6062
case rabbit_mgmt_util:disable_stats(ReqData) of
6163
false ->

deps/rabbitmq_management/test/config_schema_SUITE_data/rabbitmq_management.snippets

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,24 @@
607607
], [rabbitmq_management]
608608
},
609609

610+
{require_definition_json_extension_disabled,
611+
"management.definitions.require_json_extension = false",
612+
[
613+
{rabbitmq_management, [
614+
{require_definition_json_extension, false}
615+
]}
616+
], [rabbitmq_management]
617+
},
618+
619+
{require_definition_json_extension_enabled,
620+
"management.definitions.require_json_extension = true",
621+
[
622+
{rabbitmq_management, [
623+
{require_definition_json_extension, true}
624+
]}
625+
], [rabbitmq_management]
626+
},
627+
610628
%%
611629
%% Exotic options
612630
%%

deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ all() ->
5656
{group, definitions_group2_without_prefix},
5757
{group, definitions_group3_without_prefix},
5858
{group, definitions_group4_without_prefix},
59+
{group, definitions_group5_without_prefix},
5960
{group, default_queue_type_without_prefix}
6061
].
6162

@@ -71,6 +72,7 @@ groups() ->
7172
{definitions_group2_without_prefix, [], definitions_group2_tests()},
7273
{definitions_group3_without_prefix, [], definitions_group3_tests()},
7374
{definitions_group4_without_prefix, [], definitions_group4_tests()},
75+
{definitions_group5_without_prefix, [], definitions_group5_tests()},
7476
{default_queue_type_without_prefix, [], default_queue_type_group_tests()}
7577
].
7678

@@ -113,6 +115,14 @@ definitions_group4_tests() ->
113115
definitions_vhost_test
114116
].
115117

118+
definitions_group5_tests() ->
119+
[
120+
definitions_multipart_non_json_extension_rejected_test,
121+
definitions_multipart_non_json_extension_allowed_when_not_enforced_test,
122+
require_definition_json_extension_defaults_to_false_in_overview_test,
123+
require_definition_json_extension_enabled_in_overview_test
124+
].
125+
116126
default_queue_type_group_tests() ->
117127
[
118128
default_queue_type_fallback_in_overview_test,
@@ -2234,7 +2244,7 @@ long_definitions_multipart_test(Config) ->
22342244
Data = binary_to_list(format_for_upload(LongDefs)),
22352245
CodeExp = {group, '2xx'},
22362246
Boundary = "------------long_definitions_multipart_test",
2237-
Body = format_multipart_filedata(Boundary, [{file, "file", Data}]),
2247+
Body = format_multipart_filedata(Boundary, [{file, "definitions.json", Data}]),
22382248
ContentType = lists:concat(["multipart/form-data; boundary=", Boundary]),
22392249
MoreHeaders = [{"content-type", ContentType}, {"content-length", integer_to_list(length(Body))}],
22402250
http_upload_raw(Config, post, "/definitions", Body, "guest", "guest", CodeExp, MoreHeaders),
@@ -3492,7 +3502,7 @@ definitions_multipart_body_size_limit_test(Config) ->
34923502
Boundary = "------------definitions_multipart_body_size_limit_test",
34933503
LargeBody = list_to_binary(lists:duplicate(300, $\s)),
34943504
OversizedMultipart = format_multipart_filedata(Boundary,
3495-
[{file, "file", Data ++ binary_to_list(LargeBody)}]),
3505+
[{file, "definitions.json", Data ++ binary_to_list(LargeBody)}]),
34963506
ContentType = lists:concat(["multipart/form-data; boundary=", Boundary]),
34973507
MoreHeaders = [{"content-type", ContentType},
34983508
{"content-length", integer_to_list(length(OversizedMultipart))}],
@@ -3503,6 +3513,64 @@ definitions_multipart_body_size_limit_test(Config) ->
35033513
rpc(Config, application, unset_env, [rabbitmq_management, max_http_body_size])
35043514
end.
35053515

3516+
definitions_multipart_non_json_extension_rejected_test(Config) ->
3517+
%% When require_definition_json_extension is enabled, uploads whose filename does not
3518+
%% end in .json must be rejected with HTTP 400. The JSON response body must
3519+
%% include the reason code and the offending filename.
3520+
rpc(Config, application, set_env,
3521+
[rabbitmq_management, require_definition_json_extension, true]),
3522+
try
3523+
SmallDefs = #{users => [], vhosts => [#{name => <<"test">>}]},
3524+
Data = binary_to_list(format_for_upload(SmallDefs)),
3525+
Boundary = "------------definitions_multipart_non_json_rejected",
3526+
Body = format_multipart_filedata(Boundary, [{file, "definitions.txt", Data}]),
3527+
ContentType = lists:concat(["multipart/form-data; boundary=", Boundary]),
3528+
MoreHeaders = [{"content-type", ContentType},
3529+
{"content-length", integer_to_list(length(Body))},
3530+
auth_header("guest", "guest")],
3531+
{ok, {{_, 400, _}, _, ResBody}} =
3532+
req(Config, 0, post, "/definitions", MoreHeaders, Body),
3533+
Decoded = decode_body(ResBody),
3534+
?assertEqual(<<"unsupported_file_extension">>, maps:get(reason, Decoded)),
3535+
?assertEqual(<<"definitions.txt">>, maps:get(filename, Decoded)),
3536+
passed
3537+
after
3538+
rpc(Config, application, unset_env,
3539+
[rabbitmq_management, require_definition_json_extension])
3540+
end.
3541+
3542+
definitions_multipart_non_json_extension_allowed_when_not_enforced_test(Config) ->
3543+
%% By default (require_definition_json_extension = false), files with any extension are accepted.
3544+
SmallDefs = #{users => [], vhosts => [#{name => <<"test">>}]},
3545+
Data = binary_to_list(format_for_upload(SmallDefs)),
3546+
Boundary = "------------definitions_multipart_non_json_allowed",
3547+
Body = format_multipart_filedata(Boundary, [{file, "definitions.txt", Data}]),
3548+
ContentType = lists:concat(["multipart/form-data; boundary=", Boundary]),
3549+
MoreHeaders = [{"content-type", ContentType},
3550+
{"content-length", integer_to_list(length(Body))}],
3551+
http_upload_raw(Config, post, "/definitions", Body,
3552+
"guest", "guest", {group, '2xx'}, MoreHeaders),
3553+
passed.
3554+
3555+
require_definition_json_extension_defaults_to_false_in_overview_test(Config) ->
3556+
%% By default, require_definition_json_extension is false and must be reflected in the overview.
3557+
Overview = http_get(Config, "/overview"),
3558+
?assertEqual(false, maps:get(require_definition_json_extension, Overview)),
3559+
passed.
3560+
3561+
require_definition_json_extension_enabled_in_overview_test(Config) ->
3562+
%% When enabled, the overview endpoint must report require_definition_json_extension as true.
3563+
rpc(Config, application, set_env,
3564+
[rabbitmq_management, require_definition_json_extension, true]),
3565+
try
3566+
Overview = http_get(Config, "/overview"),
3567+
?assertEqual(true, maps:get(require_definition_json_extension, Overview)),
3568+
passed
3569+
after
3570+
rpc(Config, application, unset_env,
3571+
[rabbitmq_management, require_definition_json_extension])
3572+
end.
3573+
35063574
publish_accept_json_test(Config) ->
35073575
Headers = #{'x-forwarding' => [#{uri => <<"amqp://localhost/%2F/upstream">>}]},
35083576
Msg = msg(<<"publish_accept_json_test">>, Headers, <<"Hello world">>),

deps/rabbitmq_management/test/rabbit_mgmt_test_unit_SUITE.erl

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ groups() ->
2424
pack_binding_test,
2525
default_restrictions,
2626
path_prefix_test,
27-
regex_dos_test
27+
regex_dos_test,
28+
has_json_extension_test
2829
]},
2930
{sequential_tests, [], [
3031
referrer_policy_header_set_when_configured,
@@ -120,6 +121,18 @@ regex_dos_test(_) ->
120121
?assertEqual(false, rabbit_mgmt_util:maybe_filter_by_keyword(
121122
name, EvilRegex, [{name, TargetString}], "true")).
122123

124+
has_json_extension_test(_Config) ->
125+
%% Standard .json extension
126+
?assert(rabbit_mgmt_wm_definitions:has_json_extension(<<"definitions.json">>)),
127+
%% Case-insensitive
128+
?assert(rabbit_mgmt_wm_definitions:has_json_extension(<<"definitions.JSON">>)),
129+
?assert(rabbit_mgmt_wm_definitions:has_json_extension(<<"definitions.Json">>)),
130+
%% Non-json extensions are rejected
131+
?assertNot(rabbit_mgmt_wm_definitions:has_json_extension(<<"definitions.txt">>)),
132+
?assertNot(rabbit_mgmt_wm_definitions:has_json_extension(<<"definitions">>)),
133+
%% The atom 'unknown' (no filename provided) is rejected
134+
?assertNot(rabbit_mgmt_wm_definitions:has_json_extension(unknown)).
135+
123136
referrer_policy_header_set_when_configured(_Config) ->
124137
application:set_env(rabbitmq_management, headers,
125138
[{referrer_policy, "no-referrer"}]),

0 commit comments

Comments
 (0)