Skip to main content

src/aws@internal@providers@ecs.erl

-module(aws@internal@providers@ecs).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/aws/internal/providers/ecs.gleam").
-export([ecs_uri_allows_auth/1, fetch/2]).
-export_type([options/0, ecs_credentials/0, error/0, raw_credentials/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " ECS container credentials provider.\n"
    "\n"
    " One HTTP GET to a URL the container runtime advertises in environment\n"
    " variables — typically `http://169.254.170.2<relative-uri>` inside an\n"
    " ECS/EKS task. Response shape is the same as IMDS step 3 (a JSON\n"
    " document with `AccessKeyId`, `SecretAccessKey`, `Token`, `Expiration`).\n"
    "\n"
    " Auth token, when present, goes in the `Authorization` header — but only\n"
    " when the destination is trusted (see `ecs_uri_allows_auth`). The token is\n"
    " a bearer credential; attaching it to an arbitrary host advertised via\n"
    " `AWS_CONTAINER_CREDENTIALS_FULL_URI` would exfiltrate it (issue #28). An\n"
    " empty token value means \"no auth header at all\" (None) rather than \"send\n"
    " the empty string\".\n"
).

-type options() :: {options, binary(), gleam@option:option(binary())}.

-type ecs_credentials() :: {ecs_credentials,
        binary(),
        binary(),
        gleam@option:option(binary()),
        gleam@option:option(integer())}.

-type error() :: {unreachable, binary()} | {failed, binary()}.

-type raw_credentials() :: {raw_credentials,
        binary(),
        binary(),
        gleam@option:option(binary()),
        gleam@option:option(binary())}.

-file("src/aws/internal/providers/ecs.gleam", 191).
-spec raw_decoder() -> gleam@dynamic@decode:decoder(raw_credentials()).
raw_decoder() ->
    gleam@dynamic@decode:field(
        <<"AccessKeyId"/utf8>>,
        {decoder, fun gleam@dynamic@decode:decode_string/1},
        fun(Access_key_id) ->
            gleam@dynamic@decode:field(
                <<"SecretAccessKey"/utf8>>,
                {decoder, fun gleam@dynamic@decode:decode_string/1},
                fun(Secret_access_key) ->
                    gleam@dynamic@decode:then(
                        gleam@dynamic@decode:optionally_at(
                            [<<"Token"/utf8>>],
                            none,
                            gleam@dynamic@decode:map(
                                {decoder,
                                    fun gleam@dynamic@decode:decode_string/1},
                                fun(Field@0) -> {some, Field@0} end
                            )
                        ),
                        fun(Token) ->
                            gleam@dynamic@decode:then(
                                gleam@dynamic@decode:optionally_at(
                                    [<<"Expiration"/utf8>>],
                                    none,
                                    gleam@dynamic@decode:map(
                                        {decoder,
                                            fun gleam@dynamic@decode:decode_string/1},
                                        fun(Field@0) -> {some, Field@0} end
                                    )
                                ),
                                fun(Expiration) ->
                                    gleam@dynamic@decode:success(
                                        {raw_credentials,
                                            Access_key_id,
                                            Secret_access_key,
                                            Token,
                                            Expiration}
                                    )
                                end
                            )
                        end
                    )
                end
            )
        end
    ).

-file("src/aws/internal/providers/ecs.gleam", 212).
-spec decode_credentials(bitstring()) -> {ok, ecs_credentials()} |
    {error, error()}.
decode_credentials(Body) ->
    gleam@result:'try'(
        begin
            _pipe = gleam@bit_array:to_string(Body),
            gleam@result:replace_error(
                _pipe,
                {failed, <<"non-utf8 credentials body"/utf8>>}
            )
        end,
        fun(Text) ->
            gleam@result:'try'(
                begin
                    _pipe@1 = gleam@json:parse(Text, raw_decoder()),
                    gleam@result:map_error(
                        _pipe@1,
                        fun(_) ->
                            {failed,
                                <<"ECS response is not the expected JSON shape"/utf8>>}
                        end
                    )
                end,
                fun(Raw) ->
                    Expires_at = case erlang:element(5, Raw) of
                        {some, Ts} ->
                            case aws_ffi:parse_iso8601(Ts) of
                                {ok, T} ->
                                    {some, T};

                                {error, _} ->
                                    none
                            end;

                        none ->
                            none
                    end,
                    {ok,
                        {ecs_credentials,
                            erlang:element(2, Raw),
                            erlang:element(3, Raw),
                            erlang:element(4, Raw),
                            Expires_at}}
                end
            )
        end
    ).

-file("src/aws/internal/providers/ecs.gleam", 171).
-spec describe_http(aws@internal@http_send:http_error()) -> binary().
describe_http(Error) ->
    case Error of
        {connect_failed, Reason} ->
            <<"connect failed: "/utf8, Reason/binary>>;

        timeout ->
            <<"timeout"/utf8>>;

        {invalid_body, Reason@1} ->
            <<"invalid body: "/utf8, Reason@1/binary>>;

        {other, Reason@2} ->
            Reason@2
    end.

-file("src/aws/internal/providers/ecs.gleam", 138).
?DOC(" True if `s` is a decimal byte (0–255), i.e. a valid dotted-quad octet.\n").
-spec is_octet(binary()) -> boolean().
is_octet(S) ->
    case gleam_stdlib:parse_int(S) of
        {ok, N} ->
            (N >= 0) andalso (N =< 255);

        {error, _} ->
            false
    end.

-file("src/aws/internal/providers/ecs.gleam", 129).
?DOC(" True for any address in the `127.0.0.0/8` loopback block.\n").
-spec is_loopback_ipv4(binary()) -> boolean().
is_loopback_ipv4(Host) ->
    case gleam@string:split(Host, <<"."/utf8>>) of
        [First, B, C, D] ->
            (((First =:= <<"127"/utf8>>) andalso is_octet(B)) andalso is_octet(
                C
            ))
            andalso is_octet(D);

        _ ->
            false
    end.

-file("src/aws/internal/providers/ecs.gleam", 117).
?DOC(" Hosts allowed to receive the auth token over plain HTTP.\n").
-spec host_is_trusted(binary()) -> boolean().
host_is_trusted(Host) ->
    case Host of
        <<"localhost"/utf8>> ->
            true;

        <<"::1"/utf8>> ->
            true;

        <<"[::1]"/utf8>> ->
            true;

        <<"169.254.170.2"/utf8>> ->
            true;

        <<"169.254.170.23"/utf8>> ->
            true;

        _ ->
            is_loopback_ipv4(Host)
    end.

-file("src/aws/internal/providers/ecs.gleam", 101).
?DOC(
    " Whether the metadata URL may receive the\n"
    " `AWS_CONTAINER_AUTHORIZATION_TOKEN`. The token is a bearer credential, so\n"
    " sending it to an arbitrary host over plain HTTP would leak it (SSRF /\n"
    " credential exfiltration — issue #28). Mirroring aws-sdk-rust and\n"
    " aws-sdk-go-v2, it is only attached when the destination is trusted:\n"
    "\n"
    " - any `https` host (TLS protects the token in transit), or\n"
    " - a loopback host: `127.0.0.0/8`, IPv6 `::1` / `[::1]`, or `localhost`, or\n"
    " - the ECS (`169.254.170.2`) / EKS (`169.254.170.23`) link-local endpoints.\n"
    "\n"
    " Any other host over plain HTTP returns `False` so the caller omits the\n"
    " header entirely rather than leak the token. A URL that fails to parse is\n"
    " treated as untrusted.\n"
).
-spec ecs_uri_allows_auth(binary()) -> boolean().
ecs_uri_allows_auth(Url) ->
    case gleam_stdlib:uri_parse(Url) of
        {ok, Parsed} ->
            case gleam@option:map(
                erlang:element(2, Parsed),
                fun string:lowercase/1
            ) of
                {some, <<"https"/utf8>>} ->
                    true;

                _ ->
                    case erlang:element(4, Parsed) of
                        {some, Host} ->
                            host_is_trusted(string:lowercase(Host));

                        none ->
                            false
                    end
            end;

        {error, _} ->
            false
    end.

-file("src/aws/internal/providers/ecs.gleam", 77).
?DOC(
    " Build the auth headers for a request to `url`. The token is withheld —\n"
    " the `Authorization` header is simply omitted — when `url` is not a trusted\n"
    " destination, so an untrusted `FULL_URI` can never receive the bearer token\n"
    " (issue #28). The request still goes out unauthenticated; the metadata\n"
    " endpoint, if it really requires auth, then fails closed.\n"
).
-spec auth_headers(binary(), gleam@option:option(binary())) -> list({binary(),
    binary()}).
auth_headers(Url, Token) ->
    case Token of
        {some, T} ->
            case ecs_uri_allows_auth(Url) of
                true ->
                    [{<<"authorization"/utf8>>, T}];

                false ->
                    []
            end;

        none ->
            []
    end.

-file("src/aws/internal/providers/ecs.gleam", 161).
-spec apply_headers(
    gleam@http@request:request(bitstring()),
    list({binary(), binary()})
) -> gleam@http@request:request(bitstring()).
apply_headers(Req, Headers) ->
    case Headers of
        [] ->
            Req;

        [{K, V} | Rest] ->
            apply_headers(gleam@http@request:set_header(Req, K, V), Rest)
    end.

-file("src/aws/internal/providers/ecs.gleam", 145).
-spec build_request(gleam@http:method(), binary(), list({binary(), binary()})) -> {ok,
        gleam@http@request:request(bitstring())} |
    {error, binary()}.
build_request(Method, Url, Headers) ->
    gleam@result:'try'(
        begin
            _pipe = gleam@http@request:to(Url),
            gleam@result:replace_error(
                _pipe,
                <<"invalid URL: "/utf8, Url/binary>>
            )
        end,
        fun(Base) ->
            Req = begin
                _pipe@1 = Base,
                _pipe@2 = gleam@http@request:set_method(_pipe@1, Method),
                gleam@http@request:set_body(
                    _pipe@2,
                    gleam_stdlib:identity(<<""/utf8>>)
                )
            end,
            {ok, apply_headers(Req, Headers)}
        end
    ).

-file("src/aws/internal/providers/ecs.gleam", 48).
-spec fetch(
    fun((gleam@http@request:request(bitstring())) -> {ok,
            gleam@http@response:response(bitstring())} |
        {error, aws@internal@http_send:http_error()}),
    options()
) -> {ok, ecs_credentials()} | {error, error()}.
fetch(Send, Options) ->
    gleam@result:'try'(
        begin
            _pipe = build_request(
                get,
                erlang:element(2, Options),
                auth_headers(
                    erlang:element(2, Options),
                    erlang:element(3, Options)
                )
            ),
            gleam@result:map_error(_pipe, fun(Reason) -> {failed, Reason} end)
        end,
        fun(Req) ->
            gleam@result:'try'(
                begin
                    _pipe@1 = Send(Req),
                    gleam@result:map_error(
                        _pipe@1,
                        fun(E) ->
                            {unreachable,
                                <<"ECS metadata transport: "/utf8,
                                    (describe_http(E))/binary>>}
                        end
                    )
                end,
                fun(Resp) -> case erlang:element(2, Resp) of
                        200 ->
                            decode_credentials(erlang:element(4, Resp));

                        Other ->
                            {error,
                                {failed,
                                    <<"ECS metadata returned status "/utf8,
                                        (erlang:integer_to_binary(Other))/binary>>}}
                    end end
            )
        end
    ).