Skip to content

Commit 5396735

Browse files
authored
Generalized ECS provider v2 (#2882)
1 parent 41f9ebc commit 5396735

File tree

5 files changed

+508
-84
lines changed

5 files changed

+508
-84
lines changed

gems/aws-sdk-core/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 - Support `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` in `ECSCredentials` and also allow for ECS and EKS link-local http addresses.
5+
46
3.187.1 (2023-11-20)
57
------------------
68

gems/aws-sdk-core/lib/aws-sdk-core/ecs_credentials.rb

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
module Aws
88
# An auto-refreshing credential provider that loads credentials from
9-
# instances running in ECS.
9+
# instances running in containers.
1010
#
1111
# ecs_credentials = Aws::ECSCredentials.new(retries: 3)
1212
# ec2 = Aws::EC2::Client.new(credentials: ecs_credentials)
@@ -17,6 +17,12 @@ class ECSCredentials
1717
# @api private
1818
class Non200Response < RuntimeError; end
1919

20+
# Raised when the token file cannot be read.
21+
class TokenFileReadError < RuntimeError; end
22+
23+
# Raised when the token file is invalid.
24+
class InvalidTokenError < RuntimeError; end
25+
2026
# These are the errors we trap when attempting to talk to the
2127
# instance metadata service. Any of these imply the service
2228
# is not present, no responding or some other non-recoverable
@@ -41,7 +47,7 @@ class Non200Response < RuntimeError; end
4147
# is set and `credential_path` is not set.
4248
# @option options [String] :credential_path By default, the value of the
4349
# AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable.
44-
# @option options [String] :endpoint The ECS credential endpoint.
50+
# @option options [String] :endpoint The container credential endpoint.
4551
# By default, this is the value of the AWS_CONTAINER_CREDENTIALS_FULL_URI
4652
# environment variable. This value is ignored if `credential_path` or
4753
# ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] is set.
@@ -64,7 +70,6 @@ def initialize(options = {})
6470
endpoint = options[:endpoint] ||
6571
ENV['AWS_CONTAINER_CREDENTIALS_FULL_URI']
6672
initialize_uri(options, credential_path, endpoint)
67-
@authorization_token = ENV['AWS_CONTAINER_AUTHORIZATION_TOKEN']
6873

6974
@retries = options[:retries] || 5
7075
@http_open_timeout = options[:http_open_timeout] || 5
@@ -103,31 +108,43 @@ def initialize_relative_uri(options, path)
103108

104109
def initialize_full_uri(endpoint)
105110
uri = URI.parse(endpoint)
111+
validate_full_uri_scheme!(uri)
106112
validate_full_uri!(uri)
107-
@host = uri.host
113+
@host = uri.hostname
108114
@port = uri.port
109115
@scheme = uri.scheme
110-
@credential_path = uri.path
116+
@credential_path = uri.request_uri
117+
end
118+
119+
def validate_full_uri_scheme!(full_uri)
120+
return if full_uri.is_a?(URI::HTTP) || full_uri.is_a?(URI::HTTPS)
121+
122+
raise ArgumentError, "'#{full_uri}' must be a valid HTTP or HTTPS URI"
111123
end
112124

113125
# Validate that the full URI is using a loopback address if scheme is http.
114126
def validate_full_uri!(full_uri)
115127
return unless full_uri.scheme == 'http'
116128

117129
begin
118-
return if ip_loopback?(IPAddr.new(full_uri.host))
130+
return if valid_ip_address?(IPAddr.new(full_uri.host))
119131
rescue IPAddr::InvalidAddressError
120132
addresses = Resolv.getaddresses(full_uri.host)
121-
return if addresses.all? { |addr| ip_loopback?(IPAddr.new(addr)) }
133+
return if addresses.all? { |addr| valid_ip_address?(IPAddr.new(addr)) }
122134
end
123135

124136
raise ArgumentError,
125-
'AWS_CONTAINER_CREDENTIALS_FULL_URI must use a loopback '\
126-
'address when using the http scheme.'
137+
'AWS_CONTAINER_CREDENTIALS_FULL_URI must use a local loopback '\
138+
'or an ECS or EKS link-local address when using the http scheme.'
139+
end
140+
141+
def valid_ip_address?(ip_address)
142+
ip_loopback?(ip_address) || ecs_or_eks_ip?(ip_address)
127143
end
128144

129145
# loopback? method is available in Ruby 2.5+
130146
# Replicate the logic here.
147+
# loopback (IPv4 127.0.0.0/8, IPv6 ::1/128)
131148
def ip_loopback?(ip_address)
132149
case ip_address.family
133150
when Socket::AF_INET
@@ -139,6 +156,20 @@ def ip_loopback?(ip_address)
139156
end
140157
end
141158

159+
# Verify that the IP address is a link-local address from ECS or EKS.
160+
# ECS container host (IPv4 `169.254.170.2`)
161+
# EKS container host (IPv4 `169.254.170.23`, IPv6 `fd00:ec2::23`)
162+
def ecs_or_eks_ip?(ip_address)
163+
case ip_address.family
164+
when Socket::AF_INET
165+
[0xa9feaa02, 0xa9feaa17].include?(ip_address)
166+
when Socket::AF_INET6
167+
ip_address == 0xfd00_0ec2_0000_0000_0000_0000_0000_0023
168+
else
169+
false
170+
end
171+
end
172+
142173
def backoff(backoff)
143174
case backoff
144175
when Proc then backoff
@@ -174,10 +205,36 @@ def get_credentials
174205
http_get(conn, @credential_path)
175206
end
176207
end
208+
rescue TokenFileReadError, InvalidTokenError
209+
raise
177210
rescue StandardError
178211
'{}'
179212
end
180213

214+
def fetch_authorization_token
215+
if (path = ENV['AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE'])
216+
fetch_authorization_token_file(path)
217+
elsif (token = ENV['AWS_CONTAINER_AUTHORIZATION_TOKEN'])
218+
token
219+
end
220+
end
221+
222+
def fetch_authorization_token_file(path)
223+
File.read(path).strip
224+
rescue Errno::ENOENT
225+
raise TokenFileReadError,
226+
'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE is set '\
227+
"but the file doesn't exist: #{path}"
228+
end
229+
230+
def validate_authorization_token!(token)
231+
return unless token.include?("\r\n")
232+
233+
raise InvalidTokenError,
234+
'Invalid Authorization token: token contains '\
235+
'a newline and carriage return character.'
236+
end
237+
181238
def open_connection
182239
http = Net::HTTP.new(@host, @port, nil)
183240
http.open_timeout = @http_open_timeout
@@ -190,18 +247,27 @@ def open_connection
190247

191248
def http_get(connection, path)
192249
request = Net::HTTP::Get.new(path)
193-
request['Authorization'] = @authorization_token if @authorization_token
250+
set_authorization_token(request)
194251
response = connection.request(request)
195252
raise Non200Response unless response.code.to_i == 200
196253

197254
response.body
198255
end
199256

257+
def set_authorization_token(request)
258+
if (authorization_token = fetch_authorization_token)
259+
validate_authorization_token!(authorization_token)
260+
request['Authorization'] = authorization_token
261+
end
262+
end
263+
200264
def retry_errors(error_classes, options = {})
201265
max_retries = options[:max_retries]
202266
retries = 0
203267
begin
204268
yield
269+
rescue TokenFileReadError, InvalidTokenError
270+
raise
205271
rescue *error_classes => _e
206272
raise unless retries < max_retries
207273

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
[
2+
{
3+
"description": "should reject forbidden host in full URI",
4+
"env": {
5+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://192.168.1.1/endpoint"
6+
},
7+
"expect": {
8+
"type": "error",
9+
"reason": "'192.168.1.1' is not an allowed host"
10+
}
11+
},
12+
{
13+
"description": "should reject forbidden link-local host in full URI",
14+
"env": {
15+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://169.254.170.3/endpoint"
16+
},
17+
"expect": {
18+
"type": "error",
19+
"reason": "169.254.170.3' is not an allowed host"
20+
}
21+
},
22+
{
23+
"description": "should reject invalid token file path",
24+
"env": {
25+
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/endpoint",
26+
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE": "/full/path/to/token/file"
27+
},
28+
"token_file": {
29+
"type": "error",
30+
"errno": "ENOENT"
31+
},
32+
"expect": {
33+
"type": "error",
34+
"reason": "failed to read authorization token from '/full/path/to/token/file': no such file or directory"
35+
}
36+
},
37+
{
38+
"description": "https URI",
39+
"env": {
40+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "https://awscredentials.amazonaws.com/credentials"
41+
},
42+
"expect": {
43+
"type": "success",
44+
"request": {
45+
"method": "GET",
46+
"uri": "https://awscredentials.amazonaws.com/credentials",
47+
"headers": {}
48+
}
49+
}
50+
},
51+
{
52+
"description": "http loopback(v4) URI",
53+
"env": {
54+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://127.0.0.2/credentials"
55+
},
56+
"expect": {
57+
"type": "success",
58+
"request": {
59+
"method": "GET",
60+
"uri": "http://127.0.0.2/credentials",
61+
"headers": {}
62+
}
63+
}
64+
},
65+
{
66+
"description": "http loopback(v6) URI",
67+
"env": {
68+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://[::1]/credentials"
69+
},
70+
"expect": {
71+
"type": "success",
72+
"request": {
73+
"method": "GET",
74+
"uri": "http://[::1]/credentials",
75+
"headers": {}
76+
}
77+
}
78+
},
79+
{
80+
"description": "http link-local ECS URI",
81+
"env": {
82+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://169.254.170.2/credentials"
83+
},
84+
"expect": {
85+
"type": "success",
86+
"request": {
87+
"method": "GET",
88+
"uri": "http://169.254.170.2/credentials",
89+
"headers": {}
90+
}
91+
}
92+
},
93+
{
94+
"description": "http link-local EKS URI",
95+
"env": {
96+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://169.254.170.23/credentials"
97+
},
98+
"expect": {
99+
"type": "success",
100+
"request": {
101+
"method": "GET",
102+
"uri": "http://169.254.170.23/credentials",
103+
"headers": {}
104+
}
105+
}
106+
},
107+
{
108+
"description": "complex full URI",
109+
"env": {
110+
"AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://127.0.0.1:8080/credentials?foo=bar%20baz"
111+
},
112+
"expect": {
113+
"type": "success",
114+
"request": {
115+
"method": "GET",
116+
"uri": "http://127.0.0.1:8080/credentials?foo=bar%20baz",
117+
"headers": {}
118+
}
119+
}
120+
},
121+
{
122+
"description": "auth token from file",
123+
"env": {
124+
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/credentials-relative",
125+
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE": "/path/to/token"
126+
},
127+
"token_file": {
128+
"type": "success",
129+
"content": "Basic static%20token"
130+
},
131+
"expect": {
132+
"type": "success",
133+
"request": {
134+
"method": "GET",
135+
"uri": "http://169.254.170.2/credentials-relative",
136+
"headers": {
137+
"Authorization": "Basic static%20token"
138+
}
139+
}
140+
}
141+
},
142+
{
143+
"description": "auth token from env",
144+
"env": {
145+
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/credentials-relative",
146+
"AWS_CONTAINER_AUTHORIZATION_TOKEN": "Basic static%20token2"
147+
},
148+
"expect": {
149+
"type": "success",
150+
"request": {
151+
"method": "GET",
152+
"uri": "http://169.254.170.2/credentials-relative",
153+
"headers": {
154+
"Authorization": "Basic static%20token2"
155+
}
156+
}
157+
}
158+
}
159+
]

0 commit comments

Comments
 (0)