Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support disabling IMDS v1 #2924

Merged
merged 7 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions gems/aws-sdk-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased Changes
------------------

* Feature - Support disabling IMDSv1 in `InstanceProfileCredentials` using `ENV['AWS_EC2_METADATA_V1_DISABLED']`, `ec2_metadata_v1_disabled` shared config, or the `disable_imds_v1` credentials option.

3.185.1 (2023-10-05)
------------------

Expand Down
82 changes: 52 additions & 30 deletions gems/aws-sdk-core/lib/aws-sdk-core/instance_profile_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ class TokenExpiredError < RuntimeError; end
# @option options [String] :endpoint_mode ('IPv4') The endpoint mode for
# the instance metadata service. This is either 'IPv4' ('169.254.169.254')
# or 'IPv6' ('[fd00:ec2::254]').
# @option options [Boolean] :disable_imds_v1 (false) Disable the use of the
# legacy EC2 Metadata Service v1.
# @option options [String] :ip_address ('169.254.169.254') Deprecated. Use
# :endpoint instead. The IP address for the endpoint.
# @option options [Integer] :port (80)
Expand All @@ -77,6 +79,10 @@ def initialize(options = {})
endpoint_mode = resolve_endpoint_mode(options)
@endpoint = resolve_endpoint(options, endpoint_mode)
@port = options[:port] || 80
@disable_imds_v1 = resolve_disable_v1(options)
# Flag for if v2 flow fails, skip future attempts. This is set back to
# true if IMDS responds with a 401.
@imds_v1_fallback = false
@http_open_timeout = options[:http_open_timeout] || 1
@http_read_timeout = options[:http_read_timeout] || 1
@http_debug_output = options[:http_debug_output]
Expand Down Expand Up @@ -123,6 +129,16 @@ def resolve_endpoint(options, endpoint_mode)
end
end

def resolve_disable_v1(options)
value = options[:disable_imds_v1]
value ||= ENV['AWS_EC2_METADATA_V1_DISABLED']
value ||= Aws.shared_config.ec2_metadata_v1_disabled(
profile: options[:profile]
)
value = value.to_s.downcase if value
Aws::Util.str_2_bool(value) || false
end

def backoff(backoff)
case backoff
when Proc then backoff
Expand All @@ -141,7 +157,7 @@ def refresh
# service is responding but is returning invalid JSON documents
# in response to the GET profile credentials call.
begin
retry_errors([Aws::Json::ParseError, StandardError], max_retries: 3) do
retry_errors([Aws::Json::ParseError], max_retries: 3) do
c = Aws::Json.load(get_credentials.to_s)
if empty_credentials?(@credentials)
@credentials = Credentials.new(
Expand Down Expand Up @@ -173,7 +189,6 @@ def refresh
end
end
end

end
rescue Aws::Json::ParseError
raise Aws::Errors::MetadataParserError
Expand All @@ -191,34 +206,14 @@ def get_credentials
open_connection do |conn|
# attempt to fetch token to start secure flow first
# and rescue to failover
begin
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
unless token_set?
created_time = Time.now
token_value, ttl = http_put(
conn, METADATA_TOKEN_PATH, @token_ttl
)
@token = Token.new(token_value, ttl, created_time) if token_value && ttl
end
end
rescue *NETWORK_ERRORS
# token attempt failed, reset token
# fallback to non-token mode
@token = nil
end

fetch_token(conn) unless @imds_v1_fallback
token = @token.value if token_set?

begin
metadata = http_get(conn, METADATA_PATH_BASE, token)
profile_name = metadata.lines.first.strip
http_get(conn, METADATA_PATH_BASE + profile_name, token)
rescue TokenExpiredError
# Token has expired, reset it
# The next retry should fetch it
@token = nil
raise Non200Response
end
# disable insecure flow if we couldn't get token
# and imds v1 is disabled
raise TokenRetrivalError if token.nil? && @disable_imds_v1

_get_credentials(conn, token)
end
end
rescue
Expand All @@ -227,6 +222,35 @@ def get_credentials
end
end

def fetch_token(conn)
retry_errors(NETWORK_ERRORS, max_retries: @retries) do
unless token_set?
created_time = Time.now
token_value, ttl = http_put(
conn, METADATA_TOKEN_PATH, @token_ttl
)
@token = Token.new(token_value, ttl, created_time) if token_value && ttl
end
end
rescue *NETWORK_ERRORS
# token attempt failed, reset token
# fallback to non-token mode
@token = nil
@imds_v1_fallback = true
end

def _get_credentials(conn, token)
mullermp marked this conversation as resolved.
Show resolved Hide resolved
metadata = http_get(conn, METADATA_PATH_BASE, token)
profile_name = metadata.lines.first.strip
http_get(conn, METADATA_PATH_BASE + profile_name, token)
rescue TokenExpiredError
# Token has expired, reset it
# The next retry should fetch it
@token = nil
@imds_v1_fallback = false
raise Non200Response
end

def token_set?
@token && [email protected]?
end
Expand Down Expand Up @@ -276,8 +300,6 @@ def http_put(connection, path, ttl)
]
when 400
raise TokenRetrivalError
when 401
raise TokenExpiredError
else
raise Non200Response
end
Expand Down
1 change: 1 addition & 0 deletions gems/aws-sdk-core/lib/aws-sdk-core/shared_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ def self.config_reader(*attrs)
:use_fips_endpoint,
:ec2_metadata_service_endpoint,
:ec2_metadata_service_endpoint_mode,
:ec2_metadata_v1_disabled,
:max_attempts,
:retry_mode,
:adaptive_retry_wait_to_fill,
Expand Down
123 changes: 110 additions & 13 deletions gems/aws-sdk-core/spec/aws/instance_profile_credentials_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ module Aws

it 'can be configured using env variable with precedence' do
ENV['AWS_EC2_METADATA_SERVICE_ENDPOINT'] = endpoint
subject = InstanceProfileCredentials.new
allow_any_instance_of(Aws::SharedConfig)
.to receive(:ec2_metadata_service_endpoint_mode)
.and_return('http://124.124.124.124')
expect(subject.instance_variable_get(:@endpoint)).to eq endpoint
end

Expand Down Expand Up @@ -138,6 +140,42 @@ module Aws
end
end

describe 'disable imds v1 resolution' do
let(:disable_imds_v1) { true }

before do
allow_any_instance_of(InstanceProfileCredentials).to receive(:refresh)
end

it 'can be configured with shared config' do
allow_any_instance_of(Aws::SharedConfig)
.to receive(:ec2_metadata_v1_disabled)
.and_return(disable_imds_v1.to_s)
expect(subject.instance_variable_get(:@disable_imds_v1))
.to eq disable_imds_v1
end

it 'can be configured using env variable with precedence' do
ENV['AWS_EC2_METADATA_V1_DISABLED'] = disable_imds_v1.to_s
allow_any_instance_of(Aws::SharedConfig)
.to receive(:ec2_metadata_v1_disabled).and_return('false')
expect(subject.instance_variable_get(:@disable_imds_v1))
.to eq disable_imds_v1
end

it 'can be configured through code with precedence' do
allow_any_instance_of(Aws::SharedConfig)
.to receive(:ec2_metadata_v1_disabled)
.and_return('false')
ENV['AWS_EC2_METADATA_V1_DISABLED'] = 'false'
subject = InstanceProfileCredentials.new(
disable_imds_v1: disable_imds_v1
)
expect(subject.instance_variable_get(:@disable_imds_v1))
.to eq disable_imds_v1
end
end

describe 'without instance metadata service present' do
[
Errno::EHOSTUNREACH,
Expand Down Expand Up @@ -188,7 +226,7 @@ module Aws
].each do |error_code|
it "fails over to insecure flow for error code #{error_code}" do
stub_request(:put, "http://169.254.169.254#{token_path}")
.to_return(status: 404)
.to_return(status: error_code)
stub_request(:get, "http://169.254.169.254#{path}")
.to_return(status: 200, body: "profile-name\n")
stub_request(:get, "http://169.254.169.254#{path}profile-name")
Expand Down Expand Up @@ -219,27 +257,37 @@ module Aws
expect(c.credentials.session_token).to eq('session-token')
end
end
end

describe 'disable flag' do
let(:env) { {} }
it 'memoizes v1 fallback' do
token_stub = stub_request(:put, "http://169.254.169.254#{token_path}")
.to_return(status: 403)
profile_name_stub = stub_request(:get, "http://169.254.169.254#{path}")
.to_return(status: 200, body: "profile-name\n")
credentials_stub = stub_request(:get, "http://169.254.169.254#{path}profile-name")
.to_return(status: 200, body: resp)

before(:each) do
stub_const('ENV', env)
c = InstanceProfileCredentials.new(backoff: 0, retries: 0)
c.refresh!

expect(token_stub).to have_been_requested.once
expect(profile_name_stub).to have_been_requested.twice
expect(credentials_stub).to have_been_requested.twice
end
end

describe 'disable IMDS flag' do
it 'does not attempt to get credentials when disable flag set' do
env['AWS_EC2_METADATA_DISABLED'] = 'true'
ENV['AWS_EC2_METADATA_DISABLED'] = 'true'
expect(InstanceProfileCredentials.new.set?).to be(false)
end

it 'has a disable flag which is not case sensitive' do
env['AWS_EC2_METADATA_DISABLED'] = 'TrUe'
ENV['AWS_EC2_METADATA_DISABLED'] = 'TrUe'
expect(InstanceProfileCredentials.new.set?).to be(false)
end

it 'ignores values other than true for the disable flag secure' do
env['AWS_EC2_METADATA_DISABLED'] = '1'
it 'ignores values other than true for the disable flag (secure)' do
ENV['AWS_EC2_METADATA_DISABLED'] = '1'
expiration = Time.now.utc + 3600
resp = <<-JSON.strip
{
Expand Down Expand Up @@ -270,8 +318,8 @@ module Aws
expect(c.credentials.session_token).to eq('session-token')
end

it 'ignores values other than true for the disable flag insecure' do
env['AWS_EC2_METADATA_DISABLED'] = '1'
it 'ignores values other than true for the disable flag (insecure)' do
ENV['AWS_EC2_METADATA_DISABLED'] = '1'
expiration = Time.now.utc + 3600
resp = <<-JSON.strip
{
Expand All @@ -297,6 +345,55 @@ module Aws
end
end

describe 'disable IMDS v1 flag' do
before do
ENV['AWS_EC2_METADATA_V1_DISABLED'] = 'true'
end

it 'has a disable flag which is not case sensitive' do
ENV['AWS_EC2_METADATA_DISABLED'] = 'TrUe'
c = InstanceProfileCredentials.new(backoff: 0)
expect(c.instance_variable_get(:@disable_imds_v1)).to be(true)
end

it 'does not attempt to get credentials (insecure)' do
stub_request(:put, "http://169.254.169.254#{token_path}")
.to_return(status: 404)
expect(InstanceProfileCredentials.new(backoff: 0).set?).to be(false)
end

it 'gets credentials (secure)' do
expiration = Time.now.utc + 3600
resp = <<-JSON.strip
{
"Code" : "Success",
"LastUpdated" : "2013-11-22T20:03:48Z",
"Type" : "AWS-HMAC",
"AccessKeyId" : "akid",
"SecretAccessKey" : "secret",
"Token" : "session-token",
"Expiration" : "#{expiration.strftime('%Y-%m-%dT%H:%M:%SZ')}"
}
JSON
stub_request(:put, "http://169.254.169.254#{token_path}")
.to_return(
status: 200,
body: "my-token\n",
headers: { 'x-aws-ec2-metadata-token-ttl-seconds' => '21600' }
)
stub_request(:get, "http://169.254.169.254#{path}")
.with(headers: { 'x-aws-ec2-metadata-token' => 'my-token' })
.to_return(status: 200, body: "profile-name\n")
stub_request(:get, "http://169.254.169.254#{path}profile-name")
.with(headers: { 'x-aws-ec2-metadata-token' => 'my-token' })
.to_return(status: 200, body: resp)
c = InstanceProfileCredentials.new(backoff: 0)
expect(c.credentials.access_key_id).to eq('akid')
expect(c.credentials.secret_access_key).to eq('secret')
expect(c.credentials.session_token).to eq('session-token')
end
end

describe 'with instance metadata service present' do
let(:expiration) { Time.now.utc + 3600 }
let(:expiration2) { expiration + 3600 }
Expand Down
11 changes: 11 additions & 0 deletions gems/aws-sdk-core/spec/aws/shared_config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,17 @@ module Aws
end
end

context 'ec2_metadata_v1_disabled selection' do
it 'can resolve ec2_metadata_v1_disabled from config file' do
config = SharedConfig.new(
config_path: mock_config_file,
config_enabled: true,
profile_name: 'ec2_metadata_v1_disabled'
)
expect(config.ec2_metadata_v1_disabled).to eq('true')
end
end

context 'defaults_mode' do
it 'can resolve defaults_mode from config file' do
config = SharedConfig.new(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ ec2_metadata_service_endpoint = http://123.123.123.123
[profile ec2_metadata_service_endpoint_mode]
ec2_metadata_service_endpoint_mode = IPv6

[profile ec2_metadata_v1_disabled]
ec2_metadata_v1_disabled = true

[profile assume_role_base]
aws_access_key_id = ACCESS_KEY_BASE
aws_secret_access_key = SECRET_KEY_BASE
Expand Down
Loading