Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/ldclient_event_dispatch_httpc.erl
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ send(State, JsonEvents, PayloadId, Uri) ->

-type http_request() :: {ok, {{string(), integer(), string()}, [{string(), string()}], string() | binary()}}.

-spec process_request({error, term()} | http_request())
-spec process_request({error, term()} | http_request())
-> {ok, integer()} | {error, temporary, string()} | {error, permanent, string()}.
process_request({error, Reason}) ->
{error, temporary, Reason};
{error, temporary, ldclient_key_redaction:format_httpc_error(Reason)};
process_request({ok, {{_Version, StatusCode, _ReasonPhrase}, Headers, _Body}}) when StatusCode < 400 ->
{ok, get_server_time(Headers)};
process_request({ok, {{Version, StatusCode, ReasonPhrase}, _Headers, _Body}}) ->
Expand Down
11 changes: 10 additions & 1 deletion src/ldclient_event_process_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
-export([start_link/1, init/1]).

%% Behavior callbacks
-export([code_change/3, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-export([code_change/3, handle_call/3, handle_cast/2, handle_info/2, terminate/2, format_status/1]).

%% API
-export([
Expand Down Expand Up @@ -152,6 +152,15 @@ terminate(_Reason, _State) ->
code_change(_OldVsn, State, _Extra) ->
{ok, State}.

%% @doc Redact SDK key from state for logging
%% @private
%%
%% @end
format_status(#{state := State}) ->
#{state => State#{sdk_key => "[REDACTED]"}};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State#{sdk_key => "[REDACTED]"} would be like {...State, sdk_key: "[REDACTED]"]} in JS.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, should we log the auth suffix? That might make it more useful?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For erlang each SDK instance has a tag and that will be included in the state. So default for the default instance, and then subsequent instances you have to provide a tag for.

Copy link
Member Author

@kinyoklion kinyoklion Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which provides similar functionality to the SDK key suffix, without any potential issues from short keys, newlines, or other malformed nonsense.

format_status(Other) ->
Other.

%%===================================================================
%% Internal functions
%%===================================================================
Expand Down
101 changes: 101 additions & 0 deletions src/ldclient_key_redaction.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
%%-------------------------------------------------------------------
%% @doc SDK key redaction utilities
%% @private
%% Provides functions to safely format error reasons and other data
%% to prevent SDK keys from appearing in logs.
%% @end
%%-------------------------------------------------------------------

-module(ldclient_key_redaction).

%% API
-export([format_httpc_error/1]).
-export([format_shotgun_error/1]).

%%===================================================================
%% API
%%===================================================================

%% @doc Format httpc error for safe logging
%%
%% This function provides an abstract representation of httpc error reasons
%% that is safe to log. It only formats known error types. Unknown
%% error types are represented as "unknown error" to prevent accidental
%% exposure of SDK keys, especially in cases where the SDK key might
%% contain special characters like newlines.
%% @end
-spec format_httpc_error(Reason :: term()) -> string().
%% HTTP status codes or other integers are safe to log
format_httpc_error(StatusCode) when is_integer(StatusCode) ->
lists:flatten(io_lib:format("~b", [StatusCode]));
%% Common httpc connection errors
format_httpc_error({failed_connect, _}) ->
"failed to connect";
format_httpc_error(timeout) ->
"timeout opening connection";
format_httpc_error(etimedout) ->
"connection timeout";
format_httpc_error(econnrefused) ->
"connection refused";
format_httpc_error(enetunreach) ->
"network unreachable";
format_httpc_error(ehostunreach) ->
"host unreachable";
format_httpc_error(nxdomain) ->
"domain name not found";
format_httpc_error({tls_alert, {_, Description}}) when is_atom(Description) ->
lists:flatten(io_lib:format("tls_alert: ~p", [Description]));
format_httpc_error({tls_alert, Alert}) ->
lists:flatten(io_lib:format("tls_alert: ~p", [Alert]));
%% Socket errors
format_httpc_error({socket_closed_remotely, _, _}) ->
"socket closed remotely";
format_httpc_error(closed) ->
"connection closed";
format_httpc_error(enotconn) ->
"socket not connected";
%% Known atom errors
format_httpc_error(Reason) when is_atom(Reason) ->
atom_to_list(Reason);
%% For any unknown error type, do not expose details
format_httpc_error(_Reason) ->
"unknown error".

%% @doc Format shotgun/gun error for safe logging
%%
%% This function provides an abstract representation of shotgun error reasons
%% that is safe to log. Shotgun uses gun underneath, so errors can come from gun.
%% Unknown error types are represented as "unknown error".
%% @end
-spec format_shotgun_error(Reason :: term()) -> string().
%% HTTP status codes or other integers are safe to log
format_shotgun_error(StatusCode) when is_integer(StatusCode) ->
lists:flatten(io_lib:format("~b", [StatusCode]));
%% Known shotgun errors from open
format_shotgun_error(gun_open_failed) ->
"connection failed to open";
format_shotgun_error(gun_open_timeout) ->
"timeout opening connection";
%% Gun/socket errors
format_shotgun_error(timeout) ->
"connection timeout";
format_shotgun_error(econnrefused) ->
"connection refused";
format_shotgun_error(enetunreach) ->
"network unreachable";
format_shotgun_error(ehostunreach) ->
"host unreachable";
format_shotgun_error(nxdomain) ->
"domain name not found";
format_shotgun_error({shutdown, _}) ->
"connection shutdown";
format_shotgun_error(normal) ->
"connection closed normally";
format_shotgun_error(closed) ->
"connection closed";
%% Known atom errors
format_shotgun_error(Reason) when is_atom(Reason) ->
atom_to_list(Reason);
%% For any unknown error type, do not expose details
format_shotgun_error(_Reason) ->
"unknown error".
11 changes: 10 additions & 1 deletion src/ldclient_update_poll_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
-export([start_link/1, init/1]).

%% Behavior callbacks
-export([code_change/3, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-export([code_change/3, handle_call/3, handle_cast/2, handle_info/2, terminate/2, format_status/1]).

-type state() :: #{
sdk_key := string(),
Expand Down Expand Up @@ -107,6 +107,15 @@ terminate(_Reason, _State) ->
code_change(_OldVsn, State, _Extra) ->
{ok, State}.

%% @doc Redact SDK key from state for logging
%% @private
%%
%% @end
format_status(#{state := State}) ->
#{state => State#{sdk_key => "[REDACTED]"}};
format_status(Other) ->
Other.

%%===================================================================
%% Internal functions
%%===================================================================
Expand Down
21 changes: 14 additions & 7 deletions src/ldclient_update_stream_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ handle_info({listen}, #{stream_uri := Uri} = State) ->
handle_info({'DOWN', _Mref, process, ShotgunPid, Reason}, #{conn := ShotgunPid, backoff := Backoff} = State) ->
NewBackoff = ldclient_backoff:fail(Backoff),
_ = ldclient_backoff:fire(NewBackoff),
error_logger:warning_msg("Got DOWN message from shotgun pid with reason: ~p, will retry in ~p ms~n", [Reason, maps:get(current, NewBackoff)]),
% Reason from DOWN message could contain connection details with headers/SDK keys
SafeReason = ldclient_key_redaction:format_shotgun_error(Reason),
error_logger:warning_msg("Got DOWN message from shotgun pid with reason: ~s, will retry in ~p ms~n", [SafeReason, maps:get(current, NewBackoff)]),
{noreply, State#{conn := undefined, backoff := NewBackoff}};
handle_info({timeout, _TimerRef, listen}, State) ->
error_logger:info_msg("Reconnecting streaming connection...~n"),
Expand Down Expand Up @@ -132,13 +134,16 @@ do_listen(#{
NewBackoff = do_listen_fail_backoff(Backoff, temporary, Reason),
State#{backoff := NewBackoff};
{error, permanent, Reason} ->
% Reason here is already safe: either a sanitized string from format_shotgun_error
% or an integer status code from the do_listen/5 method.
error_logger:error_msg("Stream encountered permanent error ~p, giving up~n", [Reason]),
State;
{ok, Pid} ->
NewBackoff = ldclient_backoff:succeed(Backoff),
State#{conn := Pid, backoff := NewBackoff}
catch Code:Reason ->
NewBackoff = do_listen_fail_backoff(Backoff, Code, Reason),
catch Code:_Reason ->
% Don't pass raw exception reason as it could contain unsafe data
NewBackoff = do_listen_fail_backoff(Backoff, Code, "unexpected exception"),
State#{backoff := NewBackoff}
end.

Expand Down Expand Up @@ -171,9 +176,10 @@ do_listen(Uri, FeatureStore, Tag, GunOpts, Headers) ->
F = fun(nofin, _Ref, Bin) ->
try
process_event(parse_shotgun_event(Bin), FeatureStore, Tag)
catch Code:Reason ->
% Exception when processing event, log error, close connection
error_logger:warning_msg("Invalid SSE event error (~p): ~p", [Code, Reason]),
catch Code:_Reason ->
% Exception when processing event - don't log exception details
% as they could theoretically contain sensitive data
error_logger:warning_msg("Invalid SSE event error (~p)", [Code]),
shotgun:close(Pid)
end;
(fin, _Ref, _Bin) ->
Expand All @@ -185,7 +191,8 @@ do_listen(Uri, FeatureStore, Tag, GunOpts, Headers) ->
case shotgun:get(Pid, Path ++ Query, Headers, Options) of
{error, Reason} ->
shotgun:close(Pid),
{error, temporary, Reason};
SafeReason = ldclient_key_redaction:format_shotgun_error(Reason),
{error, temporary, SafeReason};
{ok, #{status_code := StatusCode}} when StatusCode >= 400 ->
{error, ldclient_http:is_http_error_code_recoverable(StatusCode), StatusCode};
{ok, _Ref} ->
Expand Down
85 changes: 85 additions & 0 deletions test/ldclient_key_redaction_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
-module(ldclient_key_redaction_SUITE).

-include_lib("common_test/include/ct.hrl").

%% ct functions
-export([all/0]).
-export([init_per_suite/1]).
-export([end_per_suite/1]).

%% Tests
-export([
format_httpc_error_atom/1,
format_httpc_error_integer/1,
format_httpc_error_tuple/1,
format_httpc_error_unknown/1,
format_shotgun_error_atom/1,
format_shotgun_error_integer/1,
format_shotgun_error_unknown/1
]).

%%====================================================================
%% ct functions
%%====================================================================

all() ->
[
format_httpc_error_atom,
format_httpc_error_integer,
format_httpc_error_tuple,
format_httpc_error_unknown,
format_shotgun_error_atom,
format_shotgun_error_integer,
format_shotgun_error_unknown
].

init_per_suite(Config) ->
Config.

end_per_suite(_) ->
ok.

%%====================================================================
%% Tests
%%====================================================================

format_httpc_error_atom(_) ->
% Known atom errors should be formatted safely
"timeout opening connection" = ldclient_key_redaction:format_httpc_error(timeout),
"connection refused" = ldclient_key_redaction:format_httpc_error(econnrefused),
"connection timeout" = ldclient_key_redaction:format_httpc_error(etimedout).

format_httpc_error_integer(_) ->
% HTTP status codes should be formatted as integers
"404" = ldclient_key_redaction:format_httpc_error(404),
"500" = ldclient_key_redaction:format_httpc_error(500),
"200" = ldclient_key_redaction:format_httpc_error(200).

format_httpc_error_tuple(_) ->
% Known tuple errors should be formatted safely
"failed to connect" = ldclient_key_redaction:format_httpc_error({failed_connect, []}),
"connection closed" = ldclient_key_redaction:format_httpc_error(closed).

format_httpc_error_unknown(_) ->
% Unknown error types, including those that might contain SDK keys, should be redacted
"unknown error" = ldclient_key_redaction:format_httpc_error({http_error, "sdk-12345"}),
"unknown error" = ldclient_key_redaction:format_httpc_error(["Authorization: sdk-test\n"]),
"unknown error" = ldclient_key_redaction:format_httpc_error({request, [{headers, [{"Authorization", "sdk-key-with-newline\n"}]}]}).

format_shotgun_error_atom(_) ->
% Known atom errors should be formatted safely
"connection timeout" = ldclient_key_redaction:format_shotgun_error(timeout),
"connection failed to open" = ldclient_key_redaction:format_shotgun_error(gun_open_failed),
"timeout opening connection" = ldclient_key_redaction:format_shotgun_error(gun_open_timeout),
"connection refused" = ldclient_key_redaction:format_shotgun_error(econnrefused).

format_shotgun_error_integer(_) ->
% HTTP status codes should be formatted as integers
"401" = ldclient_key_redaction:format_shotgun_error(401),
"500" = ldclient_key_redaction:format_shotgun_error(500).

format_shotgun_error_unknown(_) ->
% Unknown error types, including those that might contain SDK keys, should be redacted
"unknown error" = ldclient_key_redaction:format_shotgun_error({gun_error, "sdk-12345"}),
"unknown error" = ldclient_key_redaction:format_shotgun_error(["Headers: sdk-test\n"]),
"unknown error" = ldclient_key_redaction:format_shotgun_error({connection_error, [{auth, "sdk-malformed\nkey"}]}).
Loading