Skip to content

Commit 73fa38b

Browse files
authored
Added support for imdsv2 (#70)
* Added support for imdsv2 * fix * export fun * exporting get_imdsv2_token function
1 parent 45cf274 commit 73fa38b

File tree

4 files changed

+169
-4
lines changed

4 files changed

+169
-4
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ If not using instance metadata, set `aws_access_key` and `aws_secret_key` in `er
1616
application environment to your long-term credentials; these will be used to obtain a
1717
session token periodically.
1818

19+
## IMDSv2 Support
20+
21+
This library supports **AWS Instance Metadata Service Version 2 (IMDSv2)** by default, which
22+
provides enhanced security against SSRF attacks. IMDSv2 uses session-oriented requests with
23+
a token that must be obtained before accessing metadata.
24+
25+
### Configuration
26+
27+
- `imds_use_v2` (default: `true`) - Enable IMDSv2 support. If token retrieval fails, the
28+
library will automatically fall back to IMDSv1.
29+
- `imds_token_ttl` (default: `21600` seconds / 6 hours) - The TTL for IMDSv2 session tokens.
30+
- `imds_host` (default: `"169.254.169.254"`) - The IMDS host address.
31+
- `imds_version` (default: `"latest"`) - The IMDS API version.
32+
1933
## example
2034

2135
### Fetch an object from S3

rebar.config

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
{deps, [{jiffy, "1.1.1"}]}.
1111

12+
{profiles, [{test, [{deps, [{meck, "0.9.2"}]}]}]}.
13+
1214
{dialyzer,
1315
[{warnings, [unknown, no_return, error_handling, missing_return, extra_return]},
1416
{plt_apps, top_level_deps},

src/erliam.erl

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

1616
-export_type([iso_datetime/0]).
1717

18-
-export([httpc_profile/0, get_session_token/0, credentials/0, invalidate/0]).
18+
-export([httpc_profile/0, get_session_token/0, credentials/0, invalidate/0,
19+
get_imdsv2_token/0]).
1920

2021
%% Return the current cached credentials (crash if none are cached or credential refresher
2122
%% server isn't running).
@@ -41,3 +42,6 @@ httpc_profile() ->
4142
%% force cached credentials to be invalidated and refreshed.
4243
invalidate() ->
4344
erliam_srv:invalidate().
45+
46+
get_imdsv2_token() ->
47+
imds:get_imdsv2_token().

src/imds.erl

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
-module(imds).
88

99
-export([role_name/0, zone/0, instance_id/0, public_hostname/0, get_session_token/0,
10-
imds_response/3, imds_response/4]).
10+
imds_response/3, imds_response/4, get_imdsv2_token/0]).
1111

1212
-define(IMDS_HOST, erliam_config:g(imds_host, "169.254.169.254")).
1313
-define(IMDS_VERSION, erliam_config:g(imds_version, "latest")).
14-
-define(IMDS_TIMEOUT, 30000).
14+
-define(IMDS_TIMEOUT, erliam_config:g(imds_timeout, 30000)).
1515
-define(IMDS_RETRIES, 3).
16+
-define(IMDS_TOKEN_TTL, erliam_config:g(imds_token_ttl, "21600")). % 6 hours default
17+
-define(IMDS_USE_V2, erliam_config:g(imds_use_v2, true)). % Use IMDSv2 by default
1618

1719
%%%% API
1820

@@ -44,12 +46,40 @@ get_session_token() ->
4446
Error
4547
end.
4648

49+
-spec get_imdsv2_token() -> {ok, string()} | {error, term()}.
50+
get_imdsv2_token() ->
51+
%% Get config values at runtime so they can be overridden in tests
52+
Timeout = erliam_config:g(imds_timeout, 30000),
53+
Host = erliam_config:g(imds_host, "169.254.169.254"),
54+
Url = uri_string:normalize(["http://", Host, "/latest/api/token"]),
55+
RequestHeaders = [{"X-aws-ec2-metadata-token-ttl-seconds", ?IMDS_TOKEN_TTL}],
56+
case httpc:request(put,
57+
{Url, RequestHeaders, "", ""},
58+
[{timeout, Timeout}, {connect_timeout, Timeout}],
59+
[{body_format, binary}],
60+
erliam:httpc_profile())
61+
of
62+
{ok, {{_, 200, _}, _, Body}} ->
63+
case unicode:characters_to_list(Body) of
64+
{error, _, _} ->
65+
{error, invalid_token_unicode};
66+
{incomplete, _, _} ->
67+
{error, invalid_token_unicode};
68+
Token ->
69+
{ok, Token}
70+
end;
71+
{ok, {{_, Code, Status}, _, _}} ->
72+
{error, {bad_token_response, {Code, Status}}};
73+
{error, Reason} ->
74+
{error, Reason}
75+
end.
76+
4777
%% Make a GET request to the given URL, expecting (accepting) the given mime types, and
4878
%% with the given request timeout in milliseconds.
4979
-spec imds_response(string(), [string()], pos_integer()) ->
5080
{ok, term()} | {error, term()}.
5181
imds_response(Url, MimeTypes, Timeout) ->
52-
RequestHeaders = [{"Accept", string:join(MimeTypes, ", ")}],
82+
RequestHeaders = build_request_headers(MimeTypes),
5383
case httpc:request(get,
5484
{Url, RequestHeaders},
5585
[{timeout, Timeout}],
@@ -88,6 +118,26 @@ imds_response(Url, MimeTypes, Timeout, Retries) ->
88118

89119
%%%% INTERNAL FUNCTIONS
90120

121+
%% Build request headers for IMDS requests, including IMDSv2 token if enabled.
122+
-spec build_request_headers([string()]) -> [{string(), string()}].
123+
build_request_headers(MimeTypes) ->
124+
AcceptHeader = {"Accept", string:join(MimeTypes, ", ")},
125+
case ?IMDS_USE_V2 of
126+
true ->
127+
case get_imdsv2_token() of
128+
{ok, Token} ->
129+
[AcceptHeader, {"X-aws-ec2-metadata-token", Token}];
130+
{error, Reason} ->
131+
%% Log warning but fall back to IMDSv1
132+
error_logger:warning_msg("Failed to obtain IMDSv2 token: ~p, "
133+
"falling back to IMDSv1~n",
134+
[Reason]),
135+
[AcceptHeader]
136+
end;
137+
false ->
138+
[AcceptHeader]
139+
end.
140+
91141
%% Call the given Transform function with the result of a successful call to
92142
%% imds_response/4, or return the error which resulted from that call.
93143
-spec imds_transform_response(string(), [string()], function()) ->
@@ -205,4 +255,99 @@ metadata_response_to_proplist_test() ->
205255
|| Key <- [expiration, access_key_id, secret_access_key, token]],
206256
ok.
207257

258+
%% Test that build_request_headers returns only Accept header when IMDSv2 is disabled
259+
build_request_headers_v1_test() ->
260+
%% Temporarily disable IMDSv2 for this test
261+
OldValue = application:get_env(erliam, imds_use_v2, true),
262+
application:set_env(erliam, imds_use_v2, false),
263+
try
264+
MimeTypes = ["text/plain", "application/json"],
265+
Headers = build_request_headers(MimeTypes),
266+
%% Should only contain Accept header
267+
?assertEqual(1, length(Headers)),
268+
?assertEqual({"Accept", "text/plain, application/json"}, hd(Headers))
269+
after
270+
application:set_env(erliam, imds_use_v2, OldValue)
271+
end.
272+
273+
%% Test that build_request_headers falls back to IMDSv1 when token request fails
274+
build_request_headers_v2_fallback_test() ->
275+
with_imdsv2_enabled(fun() ->
276+
mock_imdsv2_token_failure(),
277+
try
278+
Headers = build_request_headers(["text/plain"]),
279+
?assertEqual([{"Accept", "text/plain"}], Headers)
280+
after
281+
meck:unload(httpc)
282+
end
283+
end).
284+
285+
%% Test that build_request_headers includes token header when IMDSv2 succeeds
286+
build_request_headers_v2_success_test() ->
287+
with_imdsv2_enabled(fun() ->
288+
mock_imdsv2_token_success("test-token-12345"),
289+
try
290+
Headers = build_request_headers(["text/plain"]),
291+
?assertEqual([{"Accept", "text/plain"},
292+
{"X-aws-ec2-metadata-token", "test-token-12345"}],
293+
Headers)
294+
after
295+
meck:unload(httpc)
296+
end
297+
end).
298+
299+
%% Helper function to run a test with IMDSv2 enabled
300+
with_imdsv2_enabled(Fun) ->
301+
OldValue = application:get_env(erliam, imds_use_v2, true),
302+
application:set_env(erliam, imds_use_v2, true),
303+
try
304+
Fun()
305+
after
306+
application:set_env(erliam, imds_use_v2, OldValue)
307+
end.
308+
309+
%% Helper function to mock IMDSv2 token retrieval failure
310+
mock_imdsv2_token_failure() ->
311+
meck:new(httpc, [passthrough, unstick]),
312+
meck:expect(httpc,
313+
request,
314+
fun(put, {_Url, _Headers, _, _}, _HTTPOptions, _Options, _Profile) ->
315+
{error,
316+
{failed_connect,
317+
[{to_address, {"169.254.169.254", 80}}, {inet, [inet], econnrefused}]}}
318+
end).
319+
320+
%% Helper function to mock successful IMDSv2 token retrieval
321+
mock_imdsv2_token_success(Token) ->
322+
meck:new(httpc, [passthrough, unstick]),
323+
meck:expect(httpc,
324+
request,
325+
fun(put, {_Url, _Headers, _, _}, _HTTPOptions, _Options, _Profile) ->
326+
{ok, {{ignore, 200, ignore}, [], list_to_binary(Token)}}
327+
end).
328+
329+
%% Test that imds_url generates correct URLs
330+
imds_url_test() ->
331+
Expected = "http://169.254.169.254/latest/meta-data/instance-id",
332+
?assertEqual(Expected, imds_url("instance-id")).
333+
334+
%% Test that token response parsing handles invalid JSON
335+
get_code_invalid_json_test() ->
336+
%% jiffy will throw an error for invalid JSON
337+
%% Our get_code function should catch this and return an error
338+
Result =
339+
try get_code(<<"not json">>) of
340+
Val ->
341+
Val
342+
catch
343+
_:_ ->
344+
{error, invalid_token_json}
345+
end,
346+
?assertMatch({error, _}, Result).
347+
348+
%% Test that token response parsing handles non-Success codes
349+
get_code_failure_test() ->
350+
Body = <<"{\"Code\":\"Failure\"}">>,
351+
?assertEqual({error, failed_token_response}, get_code(Body)).
352+
208353
-endif.

0 commit comments

Comments
 (0)