Skip to content

Commit eb37bfb

Browse files
authored
EC2 IMDS security updates [v2] (#2171)
* EC2 IMDS security updates * Fix ruby 1.9 syntax * More Ruby 1.9 syntax fixes * More fixes
1 parent 831d686 commit eb37bfb

File tree

5 files changed

+364
-86
lines changed

5 files changed

+364
-86
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Unreleased Changes
22
------------------
33

4+
* Feature = Aws::Core - Support EC2 IMDS updates.
5+
46
2.11.400 (2019-11-18)
57
------------------
68

aws-sdk-core/lib/aws-sdk-core/instance_profile_credentials.rb

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ class InstanceProfileCredentials
1111
# @api private
1212
class Non200Response < RuntimeError; end
1313

14+
# @api private
15+
class TokenRetrivalError < RuntimeError; end
16+
17+
# @api private
18+
class TokenExpiredError < RuntimeError; end
19+
20+
# @api private
21+
class TokenRetrivalUnavailableError < RuntimeError; end
22+
1423
# These are the errors we trap when attempting to talk to the
1524
# instance metadata service. Any of these imply the service
1625
# is not present, no responding or some other non-recoverable
@@ -26,6 +35,14 @@ class Non200Response < RuntimeError; end
2635
Non200Response,
2736
]
2837

38+
# Path base for GET request for profile and credentials
39+
# @api private
40+
METADATA_PATH_BASE = '/latest/meta-data/iam/security-credentials/'
41+
42+
# Path for PUT request for token
43+
# @api private
44+
METADATA_TOKEN_PATH = '/latest/api/token'
45+
2946
# @param [Hash] options
3047
# @option options [Integer] :retries (5) Number of times to retry
3148
# when retrieving credentials.
@@ -40,6 +57,8 @@ class Non200Response < RuntimeError; end
4057
# @option options [IO] :http_debug_output (nil) HTTP wire
4158
# traces are sent to this object. You can specify something
4259
# like $stdout.
60+
# @option options [Integer] :token_ttl (21600) Time-to-Live in seconds for
61+
# EC2 Metadata Token used for fetching Metadata Profile Credentials.
4362
def initialize options = {}
4463
@retries = options[:retries] || 5
4564
@ip_address = options[:ip_address] || '169.254.169.254'
@@ -48,11 +67,13 @@ def initialize options = {}
4867
@http_read_timeout = options[:http_read_timeout] || 5
4968
@http_debug_output = options[:http_debug_output]
5069
@backoff = backoff(options[:backoff])
70+
@token_ttl = options[:token_ttl] || 21600
5171
super
5272
end
5373

54-
# @return [Integer] The number of times to retry failed attempts to
55-
# fetch credentials from the instance metadata service. Defaults to 0.
74+
# @return [Integer] Number of times to retry when retrieving credentials
75+
# from the instance metadata service. Defaults to 0 when resolving from
76+
# the default credential chain ({Aws::CredentialProviderChain}).
5677
attr_reader :retries
5778

5879
private
@@ -93,9 +114,11 @@ def get_credentials
93114
begin
94115
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
95116
open_connection do |conn|
96-
path = '/latest/meta-data/iam/security-credentials/'
97-
profile_name = http_get(conn, path).lines.first.strip
98-
http_get(conn, path + profile_name)
117+
_token_attempt(conn)
118+
token_value = @token.value if token_set?
119+
profile_name = http_get(conn, METADATA_PATH_BASE, token_value)
120+
.lines.first.strip
121+
http_get(conn, METADATA_PATH_BASE + profile_name, token_value)
99122
end
100123
end
101124
rescue
@@ -104,6 +127,28 @@ def get_credentials
104127
end
105128
end
106129

130+
def token_set?
131+
@token && !@token.expired?
132+
end
133+
134+
# attempt to fetch token with retries baked in
135+
# would be skipped if token already set
136+
def _token_attempt(conn)
137+
begin
138+
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
139+
unless token_set?
140+
token_value, ttl = http_put(conn, METADATA_TOKEN_PATH, @token_ttl)
141+
@token = Token.new(token_value, ttl) if token_value && ttl
142+
end
143+
end
144+
rescue *NETWORK_ERRORS, TokenRetrivalUnavailableError
145+
# token attempt failed with allowable errors (those indicating
146+
# token retrieval not available on the instance), reset token to
147+
# allow safe failover to non-token mode
148+
@token = nil
149+
end
150+
end
151+
107152
def _metadata_disabled?
108153
flag = ENV["AWS_EC2_METADATA_DISABLED"]
109154
!flag.nil? && flag.downcase == "true"
@@ -118,10 +163,40 @@ def open_connection
118163
yield(http).tap { http.finish }
119164
end
120165

121-
def http_get(connection, path)
122-
response = connection.request(Net::HTTP::Get.new(path))
123-
if response.code.to_i == 200
166+
# GET request fetch profile and credentials
167+
def http_get(connection, path, token=nil)
168+
headers = {"User-Agent" => "aws-sdk-ruby2/#{VERSION}"}
169+
headers["x-aws-ec2-metadata-token"] = token if token
170+
response = connection.request(Net::HTTP::Get.new(path, headers))
171+
case response.code.to_i
172+
when 200
124173
response.body
174+
when 401
175+
raise TokenExpiredError
176+
else
177+
raise Non200Response
178+
end
179+
end
180+
181+
# PUT request fetch token with ttl
182+
def http_put(connection, path, ttl)
183+
headers = {
184+
"User-Agent" => "aws-sdk-ruby2/#{VERSION}",
185+
"x-aws-ec2-metadata-token-ttl-seconds" => ttl.to_s
186+
}
187+
response = connection.request(Net::HTTP::Put.new(path, headers))
188+
case response.code.to_i
189+
when 200
190+
[
191+
response.body,
192+
response.header["x-aws-ec2-metadata-token-ttl-seconds"].to_i
193+
]
194+
when 400
195+
raise TokenRetrivalError
196+
when 403
197+
when 404
198+
when 405
199+
raise TokenRetrivalUnavailableError
125200
else
126201
raise Non200Response
127202
end
@@ -143,5 +218,24 @@ def retry_errors(error_classes, options = {}, &block)
143218
end
144219
end
145220

221+
# @api private
222+
# Token used to fetch IMDS profile and credentials
223+
class Token
224+
225+
def initialize(value, ttl)
226+
@ttl = ttl
227+
@value = value
228+
@created_time = Time.now
229+
end
230+
231+
# [String] token value
232+
attr_reader :value
233+
234+
def expired?
235+
Time.now - @created_time > @ttl
236+
end
237+
238+
end
239+
146240
end
147241
end

aws-sdk-core/spec/aws/credential_resolution_chain_spec.rb

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ module Aws
1313
'..', 'fixtures', 'credentials', 'mock_shared_config'))
1414
}
1515

16+
let(:imds_url) {
17+
'http://169.254.169.254/latest/meta-data/iam/security-credentials/'
18+
}
19+
20+
let(:imds_token_url) {
21+
'http://169.254.169.254/latest/api/token'
22+
}
23+
1624
describe "default behavior" do
1725
before(:each) do
1826
stub_const('ENV', {})
@@ -56,28 +64,32 @@ module Aws
5664
"AR_TOKEN"
5765
)
5866
client = Aws::S3::Client.new(profile: "ar_plus_creds", region: "us-east-1")
59-
expect(client.config.credentials.access_key_id).to eq("AR_AKID")
67+
expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID")
6068
end
6169

6270
it 'prefers shared credential file static credentials over shared config' do
6371
client = Aws::S3::Client.new(profile: "credentials_first", region: "us-east-1")
64-
expect(client.config.credentials.access_key_id).to eq("ACCESS_KEY_CRD")
72+
expect(client.config.credentials.credentials.access_key_id).to eq("ACCESS_KEY_CRD")
6573
end
6674

6775
it 'will source static credentials from shared config after shared credentials' do
6876
client = Aws::S3::Client.new(profile: "incomplete_cred", region: "us-east-1")
69-
expect(client.config.credentials.access_key_id).to eq("ACCESS_KEY_SC1")
77+
expect(client.config.credentials.credentials.access_key_id).to eq("ACCESS_KEY_SC1")
7078
end
7179

7280
it 'attempts to fetch metadata credentials last' do
73-
stub_request(
74-
:get,
75-
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
76-
).to_return(:status => 200, :body => "profile-name\n")
77-
stub_request(
78-
:get,
79-
"http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name"
80-
).to_return(:status => 200, :body => <<-JSON.strip)
81+
stub_request(:put, imds_token_url)
82+
.to_return(
83+
:status => 200,
84+
:body => "my-token\n",
85+
:headers => {"x-aws-ec2-metadata-token-ttl-seconds" => "21600"}
86+
)
87+
stub_request(:get, imds_url)
88+
.with(:headers => {"x-aws-ec2-metadata-token" => "my-token"})
89+
.to_return(:status => 200, :body => "profile-name\n")
90+
stub_request(:get, "#{imds_url}profile-name")
91+
.with(:headers => {"x-aws-ec2-metadata-token" => "my-token"})
92+
.to_return(:status => 200, :body => <<-JSON.strip)
8193
{
8294
"Code" : "Success",
8395
"LastUpdated" : "2013-11-22T20:03:48Z",
@@ -89,11 +101,11 @@ module Aws
89101
}
90102
JSON
91103
client = Aws::S3::Client.new(profile: "nonexistant", region: "us-east-1")
92-
expect(client.config.credentials.access_key_id).to eq("akid-md")
104+
expect(client.config.credentials.credentials.access_key_id).to eq("akid-md")
93105
end
94106

95107
describe 'Assume Role Resolution' do
96-
it 'will not assume a role without source_profile present' do
108+
it 'will not assume a role without a source present' do
97109
expect {
98110
Aws::S3::Client.new(profile: "ar_no_src", region: "us-east-1")
99111
}.to raise_error(Errors::NoSourceProfileError)
@@ -114,7 +126,7 @@ module Aws
114126
"AR_TOKEN"
115127
)
116128
client = Aws::S3::Client.new(profile: "assumerole_sc", region: "us-east-1")
117-
expect(client.config.credentials.access_key_id).to eq("AR_AKID")
129+
expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID")
118130
end
119131

120132
it 'will then try to assume a role from shared config' do
@@ -126,7 +138,7 @@ module Aws
126138
"AR_TOKEN"
127139
)
128140
client = Aws::S3::Client.new(profile: "ar_from_self", region: "us-east-1")
129-
expect(client.config.credentials.access_key_id).to eq("AR_AKID")
141+
expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID")
130142
end
131143

132144
it 'will assume a role from config using source credentials in shared credentials' do
@@ -138,9 +150,10 @@ module Aws
138150
"AR_TOKEN"
139151
)
140152
client = Aws::S3::Client.new(profile: "creds_from_sc", region: "us-east-1")
141-
expect(client.config.credentials.access_key_id).to eq("AR_AKID")
153+
expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID")
142154
end
143155
end
156+
144157
end
145158

146159
describe "AWS_SDK_CONFIG_OPT_OUT set" do
@@ -165,7 +178,7 @@ module Aws
165178
profile: "fooprofile",
166179
region: "us-east-1"
167180
)
168-
expect(client.config.credentials.access_key_id).to eq("ACCESS_DIRECT")
181+
expect(client.config.credentials.credentials.access_key_id).to eq("ACCESS_DIRECT")
169182
end
170183

171184
it 'prefers ENV credentials over shared config' do
@@ -174,7 +187,7 @@ module Aws
174187
"AWS_SECRET_ACCESS_KEY" => "SECRET_ENV_STUB"
175188
})
176189
client = Aws::S3::Client.new(profile: "fooprofile", region: "us-east-1")
177-
expect(client.config.credentials.access_key_id).to eq("AKID_ENV_STUB")
190+
expect(client.config.credentials.credentials.access_key_id).to eq("AKID_ENV_STUB")
178191
end
179192

180193
it 'will not load credentials from shared config' do
@@ -188,14 +201,18 @@ module Aws
188201
end
189202

190203
it 'attempts to fetch metadata credentials last' do
191-
stub_request(
192-
:get,
193-
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
194-
).to_return(:status => 200, :body => "profile-name\n")
195-
stub_request(
196-
:get,
197-
"http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name"
198-
).to_return(:status => 200, :body => <<-JSON.strip)
204+
stub_request(:put, imds_token_url)
205+
.to_return(
206+
:status => 200,
207+
:body => "my-token\n",
208+
:headers => {"x-aws-ec2-metadata-token-ttl-seconds" => "21600"}
209+
)
210+
stub_request(:get, imds_url)
211+
.with(:headers => {"x-aws-ec2-metadata-token" => "my-token"})
212+
.to_return(:status => 200, :body => "profile-name\n")
213+
stub_request(:get, "#{imds_url}profile-name")
214+
.with(:headers => {"x-aws-ec2-metadata-token" => "my-token"})
215+
.to_return(:status => 200, :body => <<-JSON.strip)
199216
{
200217
"Code" : "Success",
201218
"LastUpdated" : "2013-11-22T20:03:48Z",
@@ -207,7 +224,7 @@ module Aws
207224
}
208225
JSON
209226
client = Aws::S3::Client.new(profile: "nonexistant", region: "us-east-1")
210-
expect(client.config.credentials.access_key_id).to eq("akid-md")
227+
expect(client.config.credentials.credentials.access_key_id).to eq("akid-md")
211228
end
212229
end
213230

0 commit comments

Comments
 (0)