Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions apps/emqx_gateway/src/bhvrs/emqx_gateway_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,10 @@ handle_msg(
channel = Channel
}
) ->
?SLOG(debug, #{msg => "received_udp_proxy_data", data => Data}),
Oct = iolist_size(Data),
%% The raw datagram may carry credentials (e.g. CoAP query parameters);
%% `packet_received' logs the parsed packet with frame-level redaction.
?SLOG(debug, #{msg => "received_udp_proxy_data", size => Oct}),
inc_counter(incoming_bytes, Oct),
Ctx = ChannMod:info(ctx, Channel),
ok = emqx_gateway_ctx:metrics_inc(Ctx, 'bytes.received', Oct),
Expand Down Expand Up @@ -856,13 +858,13 @@ parse_incoming(
channel = Channel
}
) ->
Oct = iolist_size(Data),
%% Raw data may carry credentials; `packet_received' logs the parsed packet
%% with frame-level redaction.
?SLOG(debug, #{
msg => "received_data",
size => iolist_size(Data),
type => "hex",
bin => binary_to_list(binary:encode_hex(Data))
size => Oct
}),
Oct = iolist_size(Data),
inc_counter(incoming_bytes, Oct),
Ctx = ChannMod:info(ctx, Channel),
ok = emqx_gateway_ctx:metrics_inc(Ctx, 'bytes.received', Oct),
Expand Down Expand Up @@ -1008,14 +1010,14 @@ send(
channel = Channel
}
) ->
Oct = iolist_size(IoData),
%% The serialized datagram may carry credentials (e.g. the CoAP session
%% token); `send_packet' logs the parsed packet with frame-level redaction.
?SLOG(debug, #{
msg => "send_data",
size => iolist_size(IoData),
type => "hex",
iodata => IoData
size => Oct
}),
Ctx = ChannMod:info(ctx, Channel),
Oct = iolist_size(IoData),
ok = emqx_gateway_ctx:metrics_inc(Ctx, 'bytes.sent', Oct),
inc_counter(outgoing_bytes, Oct),
case esockd_send(IoData, State) of
Expand Down
7 changes: 5 additions & 2 deletions apps/emqx_gateway_coap/src/emqx_coap_channel.erl
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ check_auth_state(Msg, #channel{connection_required = true} = Channel) ->
%% Connection mode policy: reject requests without token/clientid.
?SLOG(debug, #{
msg => "token_required_in_conn_mode",
message => emqx_utils:redact(Msg)
message => emqx_coap_frame:redact(Msg)
}),
missing_token_or_clientid_reply(Msg, Channel);
_ ->
Expand Down Expand Up @@ -684,9 +684,12 @@ process_connect(
RandVal = rand:uniform(?TOKEN_MAXIMUM),
Token = erlang:list_to_binary(erlang:integer_to_list(RandVal)),
NResult = Result#{events => [{event, connected}]},
%% The token is a credential: wrap it so that it cannot leak through
%% the packet debug logs. `emqx_coap_frame:serialize_pkt/2' unwraps it.
SensitiveToken = emqx_secret:wrap(Token),
iter(
Iter,
reply({ok, created}, Token, Msg, NResult),
reply({ok, created}, SensitiveToken, Msg, NResult),
Channel#channel{token = Token}
);
{error, Reason} ->
Expand Down
65 changes: 63 additions & 2 deletions apps/emqx_gateway_coap/src/emqx_coap_frame.erl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
is_message/1
]).

%% Redact credentials from a CoAP message before it is logged.
-export([redact/1]).

-include("emqx_coap.hrl").
-include_lib("emqx/include/types.hrl").

Expand Down Expand Up @@ -79,7 +82,14 @@ serialize_pkt(
Head =
<<?VERSION:2, (encode_type(Type)):2, TKL:4, Class:3, Code:5, MsgId:16, Token:TKL/binary>>,
FlatOpts = flatten_options(Options),
encode_option_list(FlatOpts, 0, Head, Payload).
encode_option_list(FlatOpts, 0, Head, unwrap_payload(Payload)).

%% Payloads carrying credentials are wrapped in `emqx_secret' by the channel so
%% that they cannot leak through debug logs; unwrap them for the wire. Plain
%% payloads pass through `emqx_secret:unwrap/1' unchanged.
-spec unwrap_payload(binary() | emqx_secret:t(binary())) -> binary().
unwrap_payload(Payload) ->
emqx_secret:unwrap(Payload).

-spec encode_type(message_type()) -> 0..3.
encode_type(con) -> 0;
Expand Down Expand Up @@ -478,7 +488,58 @@ class_code_to_method({5, 05}) -> {error, proxying_not_supported};
class_code_to_method(_) -> undefined.

format(Msg) ->
io_lib:format("~p", [emqx_utils:redact(Msg)]).
io_lib:format("~p", [redact(Msg)]).

-spec redact(term()) -> term().
redact(Msg) ->
emqx_utils:redact(redact_for_log(Msg)).

redact_for_log(Msg = #coap_message{payload = Payload}) ->
Msg#coap_message{
options = redact_options(Msg#coap_message.options),
payload = redact_payload(Payload)
};
redact_for_log(Msg) ->
Msg.

%% Secrets are wrapped in `emqx_secret' where they are produced; render them
%% redacted instead of exposing the wrapped value.
redact_payload(Payload) ->
case is_wrapped_secret(Payload) of
true -> <<"******">>;
false -> Payload
end.

is_wrapped_secret(Fun) when is_function(Fun, 0) ->
case erlang:fun_info(Fun, module) of
{module, emqx_secret} -> true;
_ -> false
end;
is_wrapped_secret(_) ->
false.

%% Credentials may be sent with the short query aliases (`t', `p'). Keep the
%% original query keys, redact only the values so that the log still shows the
%% request as it was sent.
redact_options(#{uri_query := Query} = Options) when is_map(Query) ->
Options#{uri_query => redact_query(Query)};
redact_options(Options) ->
Options.

redact_query(Query) ->
maps:map(
fun(Key, Value) ->
case is_sensitive_query_key(Key) of
true -> <<"******">>;
false -> Value
end
end,
Query
).

is_sensitive_query_key(Key) ->
LongKey = proplists:get_value(Key, ?QUERY_PARAMS_MAPPING, Key),
emqx_utils_redact:is_sensitive_key(LongKey).

type(_) ->
coap.
Expand Down
84 changes: 84 additions & 0 deletions apps/emqx_gateway_coap/test/emqx_coap_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,58 @@ t_mountpoint_after_authn(_) ->
end,
ok.

t_clientid_override_not_retained(_) ->
ok = meck:expect(
emqx_access_control,
authenticate,
fun(_) ->
{ok, #{
clientid_override => <<"trusted-id">>,
client_attrs => #{<<"tenant">> => <<"tenant-1">>}
}}
end
),
OldConf = emqx:get_raw_config([gateway, coap]),
{ok, _} = emqx_gateway_conf:update_gateway(
coap,
OldConf#{<<"mountpoint">> => <<"coap/${client_attrs.tenant}/${clientid}/">>}
),
try
Action = fun(Channel) ->
Token = connection(Channel),
timer:sleep(100),
#{clientinfo := ClientInfo} = emqx_gateway_cm:get_chan_info(coap, <<"client1">>),
?assertEqual(<<"client1">>, maps:get(clientid, ClientInfo)),
?assertEqual(false, maps:is_key(clientid_override, ClientInfo)),
?assertEqual(<<"coap/tenant-1/client1/">>, maps:get(mountpoint, ClientInfo)),
disconnection(Channel, Token),
ok
end,
do(Action)
after
{ok, _} = emqx_gateway_conf:update_gateway(coap, OldConf)
end,
ok.

t_connection_token_not_logged(_) ->
Reports = emqx_cth_log_capture:capture(debug, fun() ->
do(fun(Channel) ->
Token = connection(Channel),
put(coap_session_token, Token),
disconnection(Channel, Token)
end)
end),
TokenBin = list_to_binary(get(coap_session_token)),
?assertNotEqual([], Reports),
%% No debug log may carry the token. Checking every report also covers the
%% raw datagram dumps, which would otherwise defeat frame-level redaction.
Leaks = [
Report
|| Report <- Reports,
binary:match(term_to_binary(Report), TokenBin) =/= nomatch
],
?assertEqual([], Leaks).

t_connection_with_short_param_name(_) ->
Action = fun(Channel) ->
%% connection
Expand Down Expand Up @@ -718,6 +770,38 @@ t_request_with_partial_token_params(_) ->
true
end).

%% A request rejected in connection mode must not leak credentials sent with the
%% short query aliases (`t', `p') into the debug log.
t_rejected_request_does_not_log_short_credentials(_) ->
Secret = <<"short-password-value">>,
Reports = emqx_cth_log_capture:capture(debug, fun() ->
with_connection(fun(Channel, _Token) ->
URI = compose_uri(
?PS_PREFIX ++ "/short_credential",
#{"p" => Secret},
false
),
?assertMatch(
{error, bad_request, _},
do_request(Channel, URI, make_req(post, <<"x">>))
),
put(coap_rejected_secret, Secret),
true
end)
end),
%% The rejection path must have been exercised.
?assertNotEqual(
[],
[R || R = #{msg := "token_required_in_conn_mode"} <- Reports]
),
RejectedSecret = get(coap_rejected_secret),
Leaks = [
Report
|| Report <- Reports,
binary:match(term_to_binary(Report), RejectedSecret) =/= nomatch
],
?assertEqual([], Leaks).

t_token_takeover_across_udp_sessions(_) ->
{ok, Sock1, Channel1} = er_coap_udp_socket:connect({127, 0, 0, 1}, 5683),
Token = connection(Channel1),
Expand Down
60 changes: 60 additions & 0 deletions apps/emqx_gateway_coap/test/emqx_coap_frame_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

-module(emqx_coap_frame_tests).

-include("emqx_coap.hrl").
-include_lib("eunit/include/eunit.hrl").

format_redacts_sensitive_uri_query_test() ->
Expand All @@ -24,3 +25,62 @@ format_redacts_sensitive_uri_query_test() ->
maps:values(Query)
),
?assertNotEqual(nomatch, binary:match(Formatted, <<"******">>)).

format_redacts_short_uri_query_credentials_test() ->
Query = #{
<<"c">> => <<"client1">>,
<<"u">> => <<"admin">>,
<<"p">> => <<"password-value">>,
<<"t">> => <<"session-token-value">>
},
Msg = emqx_coap_message:request(
con, post, <<>>, #{uri_path => [<<"mqtt">>, <<"connection">>], uri_query => Query}
),
Formatted = iolist_to_binary(emqx_coap_frame:format(Msg)),
?assertEqual(nomatch, binary:match(Formatted, <<"password-value">>)),
?assertEqual(nomatch, binary:match(Formatted, <<"session-token-value">>)),
%% Only the values are redacted: the keys stay as the client sent them and
%% non-sensitive parameters remain readable.
?assertNotEqual(nomatch, binary:match(Formatted, <<"<<\"p\">>">>)),
?assertNotEqual(nomatch, binary:match(Formatted, <<"<<\"t\">>">>)),
?assertEqual(nomatch, binary:match(Formatted, <<"password">>)),
?assertEqual(nomatch, binary:match(Formatted, <<"token">>)),
?assertNotEqual(nomatch, binary:match(Formatted, <<"client1">>)),
?assertNotEqual(nomatch, binary:match(Formatted, <<"admin">>)).

format_redacts_wrapped_secret_payload_test() ->
Token = <<"3606183915">>,
Request = emqx_coap_message:request(con, post, <<>>, #{}),
Msg = emqx_coap_message:piggyback({ok, created}, emqx_secret:wrap(Token), Request),
Formatted = iolist_to_binary(emqx_coap_frame:format(Msg)),
?assertEqual(nomatch, binary:match(Formatted, Token)),
%% The non-sensitive metadata is preserved for troubleshooting.
?assertNotEqual(nomatch, binary:match(Formatted, <<"created">>)),
?assertNotEqual(nomatch, binary:match(Formatted, <<"******">>)).

serialize_unwraps_wrapped_secret_payload_test() ->
Token = <<"3606183915">>,
Request = emqx_coap_message:request(con, post, <<>>, #{}),
Msg0 = emqx_coap_message:piggyback({ok, created}, emqx_secret:wrap(Token), Request),
Msg = Msg0#coap_message{id = 1, token = <<>>},
Bin = emqx_coap_frame:serialize_pkt(Msg, emqx_coap_frame:serialize_opts()),
{ok, Decoded, <<>>, _} = emqx_coap_frame:parse(Bin, #{}),
%% The token is redacted in logs but still delivered on the wire.
?assertEqual(Token, Decoded#coap_message.payload).

%% `redact/1' is the alias-aware redaction reused outside of `format/1', e.g. by
%% the channel when it logs a rejected request.
redact_masks_short_uri_query_credentials_test() ->
Query = #{
<<"c">> => <<"client1">>,
<<"p">> => <<"password-value">>,
<<"t">> => <<"session-token-value">>
},
Msg = emqx_coap_message:request(
con, post, <<>>, #{uri_path => [<<"ps">>, <<"topic">>], uri_query => Query}
),
#coap_message{options = #{uri_query := Redacted}} = emqx_coap_frame:redact(Msg),
?assertEqual(<<"******">>, maps:get(<<"p">>, Redacted)),
?assertEqual(<<"******">>, maps:get(<<"t">>, Redacted)),
%% Keys are kept as sent and non-sensitive values stay readable.
?assertEqual(<<"client1">>, maps:get(<<"c">>, Redacted)).
9 changes: 9 additions & 0 deletions changes/ee/fix-19037.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Gateway debug logs no longer contain raw datagrams: `received_data`,
`received_udp_proxy_data` and `send_data` now log the byte size only, because
the encoded packet may carry credentials. The parsed packet is still logged
with protocol-level redaction.

The CoAP Gateway additionally redacts the session token issued on
`POST /mqtt/connection` and credentials sent with the short query aliases
(`t`, `p`), both in the parsed packet logs and in the log of rejected
requests.
Loading