PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
media-cloud-sync / includes / sdk / s3 / Aws / Credentials / InstanceProfileProvider.php

InstanceProfileProvider.php in Media Cloud Sync 1.4.2, at includes/sdk/s3/Aws/Credentials/InstanceProfileProvider.php

325 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Dudlewebs\WPMCS\s3\Aws\Credentials;
4
5 use Dudlewebs\WPMCS\s3\Aws\Configuration\ConfigurationResolver;
6 use Dudlewebs\WPMCS\s3\Aws\Exception\CredentialsException;
7 use Dudlewebs\WPMCS\s3\Aws\Exception\InvalidJsonException;
8 use Dudlewebs\WPMCS\s3\Aws\Sdk;
9 use Dudlewebs\WPMCS\s3\GuzzleHttp\Exception\TransferException;
10 use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise;
11 use Dudlewebs\WPMCS\s3\GuzzleHttp\Psr7\Request;
12 use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise\PromiseInterface;
13 use Dudlewebs\WPMCS\s3\Psr\Http\Message\ResponseInterface;
14 /**
15 * Credential provider that provides credentials from the EC2 metadata service.
16 */
17 class InstanceProfileProvider
18 {
19 const CRED_PATH = 'meta-data/iam/security-credentials/';
20 const TOKEN_PATH = 'api/token';
21 const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED';
22 const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT';
23 const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS';
24 const CFG_EC2_METADATA_V1_DISABLED = 'ec2_metadata_v1_disabled';
25 const CFG_EC2_METADATA_SERVICE_ENDPOINT = 'ec2_metadata_service_endpoint';
26 const CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE = 'ec2_metadata_service_endpoint_mode';
27 const DEFAULT_TIMEOUT = 1.0;
28 const DEFAULT_RETRIES = 3;
29 const DEFAULT_TOKEN_TTL_SECONDS = 21600;
30 const DEFAULT_AWS_EC2_METADATA_V1_DISABLED = \false;
31 const ENDPOINT_MODE_IPv4 = 'IPv4';
32 const ENDPOINT_MODE_IPv6 = 'IPv6';
33 const DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT = 'http://169.254.169.254';
34 const DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT = 'http://[fd00:ec2::254]';
35 /** @var string */
36 private $profile;
37 /** @var callable */
38 private $client;
39 /** @var int */
40 private $retries;
41 /** @var int */
42 private $attempts;
43 /** @var float|mixed */
44 private $timeout;
45 /** @var bool */
46 private $secureMode = \true;
47 /** @var bool|null */
48 private $ec2MetadataV1Disabled;
49 /** @var string */
50 private $endpoint;
51 /** @var string */
52 private $endpointMode;
53 /** @var array */
54 private $config;
55 /**
56 * The constructor accepts the following options:
57 *
58 * - timeout: Connection timeout, in seconds.
59 * - profile: Optional EC2 profile name, if known.
60 * - retries: Optional number of retries to be attempted.
61 * - ec2_metadata_v1_disabled: Optional for disabling the fallback to IMDSv1.
62 * - endpoint: Optional for overriding the default endpoint to be used for fetching credentials.
63 * The value must contain a valid URI scheme. If the URI scheme is not https, it must
64 * resolve to a loopback address.
65 * - endpoint_mode: Optional for overriding the default endpoint mode (IPv4|IPv6) to be used for
66 * resolving the default endpoint.
67 * - use_aws_shared_config_files: Decides whether the shared config file should be considered when
68 * using the ConfigurationResolver::resolve method.
69 *
70 * @param array $config Configuration options.
71 */
72 public function __construct(array $config = [])
73 {
74 $this->timeout = (float) \getenv(self::ENV_TIMEOUT) ?: $config['timeout'] ?? self::DEFAULT_TIMEOUT;
75 $this->profile = $config['profile'] ?? null;
76 $this->retries = (int) \getenv(self::ENV_RETRIES) ?: $config['retries'] ?? self::DEFAULT_RETRIES;
77 $this->client = $config['client'] ?? \Dudlewebs\WPMCS\s3\Aws\default_http_handler();
78 $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null;
79 $this->endpoint = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? null;
80 if (!empty($this->endpoint) && !$this->isValidEndpoint($this->endpoint)) {
81 throw new \InvalidArgumentException('The provided URI "' . $this->endpoint . '" is invalid, or contains an unsupported host');
82 }
83 $this->endpointMode = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? null;
84 $this->config = $config;
85 }
86 /**
87 * Loads instance profile credentials.
88 *
89 * @return PromiseInterface
90 */
91 public function __invoke($previousCredentials = null)
92 {
93 $this->attempts = 0;
94 return Promise\Coroutine::of(function () use($previousCredentials) {
95 // Retrieve token or switch out of secure mode
96 $token = null;
97 while ($this->secureMode && \is_null($token)) {
98 try {
99 $token = (yield $this->request(self::TOKEN_PATH, 'PUT', ['x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS]));
100 } catch (TransferException $e) {
101 if ($this->getExceptionStatusCode($e) === 500 && $previousCredentials instanceof Credentials) {
102 goto generateCredentials;
103 } elseif ($this->shouldFallbackToIMDSv1() && (!\method_exists($e, 'getResponse') || empty($e->getResponse()) || !\in_array($e->getResponse()->getStatusCode(), [400, 500, 502, 503, 504]))) {
104 $this->secureMode = \false;
105 } else {
106 $this->handleRetryableException($e, [], $this->createErrorMessage('Error retrieving metadata token'));
107 }
108 }
109 $this->attempts++;
110 }
111 // Set token header only for secure mode
112 $headers = [];
113 if ($this->secureMode) {
114 $headers = ['x-aws-ec2-metadata-token' => $token];
115 }
116 // Retrieve profile
117 while (!$this->profile) {
118 try {
119 $this->profile = (yield $this->request(self::CRED_PATH, 'GET', $headers));
120 } catch (TransferException $e) {
121 // 401 indicates insecure flow not supported, switch to
122 // attempting secure mode for subsequent calls
123 if (!empty($this->getExceptionStatusCode($e)) && $this->getExceptionStatusCode($e) === 401) {
124 $this->secureMode = \true;
125 }
126 $this->handleRetryableException($e, ['blacklist' => [401, 403]], $this->createErrorMessage($e->getMessage()));
127 }
128 $this->attempts++;
129 }
130 // Retrieve credentials
131 $result = null;
132 while ($result == null) {
133 try {
134 $json = (yield $this->request(self::CRED_PATH . $this->profile, 'GET', $headers));
135 $result = $this->decodeResult($json);
136 } catch (InvalidJsonException $e) {
137 $this->handleRetryableException($e, ['blacklist' => [401, 403]], $this->createErrorMessage('Invalid JSON response, retries exhausted'));
138 } catch (TransferException $e) {
139 // 401 indicates insecure flow not supported, switch to
140 // attempting secure mode for subsequent calls
141 if (($this->getExceptionStatusCode($e) === 500 || \strpos($e->getMessage(), "cURL error 28") !== \false) && $previousCredentials instanceof Credentials) {
142 goto generateCredentials;
143 } elseif (!empty($this->getExceptionStatusCode($e)) && $this->getExceptionStatusCode($e) === 401) {
144 $this->secureMode = \true;
145 }
146 $this->handleRetryableException($e, ['blacklist' => [401, 403]], $this->createErrorMessage($e->getMessage()));
147 }
148 $this->attempts++;
149 }
150 generateCredentials:
151 if (!isset($result)) {
152 $credentials = $previousCredentials;
153 } else {
154 $credentials = new Credentials($result['AccessKeyId'], $result['SecretAccessKey'], $result['Token'], \strtotime($result['Expiration']), $result['AccountId'] ?? null, CredentialSources::IMDS);
155 }
156 if ($credentials->isExpired()) {
157 $credentials->extendExpiration();
158 }
159 (yield $credentials);
160 });
161 }
162 /**
163 * @param string $url
164 * @param string $method
165 * @param array $headers
166 * @return PromiseInterface Returns a promise that is fulfilled with the
167 * body of the response as a string.
168 */
169 private function request($url, $method = 'GET', $headers = [])
170 {
171 $disabled = \getenv(self::ENV_DISABLE) ?: \false;
172 if (\strcasecmp($disabled, 'true') === 0) {
173 throw new CredentialsException($this->createErrorMessage('EC2 metadata service access disabled'));
174 }
175 $fn = $this->client;
176 $request = new Request($method, $this->resolveEndpoint() . $url);
177 $userAgent = 'aws-sdk-php/' . Sdk::VERSION;
178 if (\defined('Dudlewebs\\WPMCS\\s3\\HHVM_VERSION')) {
179 $userAgent .= ' HHVM/' . HHVM_VERSION;
180 }
181 $userAgent .= ' ' . \Dudlewebs\WPMCS\s3\Aws\default_user_agent();
182 $request = $request->withHeader('User-Agent', $userAgent);
183 foreach ($headers as $key => $value) {
184 $request = $request->withHeader($key, $value);
185 }
186 return $fn($request, ['timeout' => $this->timeout])->then(function (ResponseInterface $response) {
187 return (string) $response->getBody();
188 })->otherwise(function (array $reason) {
189 $reason = $reason['exception'];
190 if ($reason instanceof TransferException) {
191 throw $reason;
192 }
193 $msg = $reason->getMessage();
194 throw new CredentialsException($this->createErrorMessage($msg));
195 });
196 }
197 private function handleRetryableException(\Exception $e, $retryOptions, $message)
198 {
199 $isRetryable = \true;
200 if (!empty($status = $this->getExceptionStatusCode($e)) && isset($retryOptions['blacklist']) && \in_array($status, $retryOptions['blacklist'])) {
201 $isRetryable = \false;
202 }
203 if ($isRetryable && $this->attempts < $this->retries) {
204 \sleep((int) \pow(1.2, $this->attempts));
205 } else {
206 throw new CredentialsException($message);
207 }
208 }
209 private function getExceptionStatusCode(\Exception $e)
210 {
211 if (\method_exists($e, 'getResponse') && !empty($e->getResponse())) {
212 return $e->getResponse()->getStatusCode();
213 }
214 return null;
215 }
216 private function createErrorMessage($previous)
217 {
218 return "Error retrieving credentials from the instance profile " . "metadata service. ({$previous})";
219 }
220 private function decodeResult($response)
221 {
222 $result = \json_decode($response, \true);
223 if (\json_last_error() > 0) {
224 throw new InvalidJsonException();
225 }
226 if ($result['Code'] !== 'Success') {
227 throw new CredentialsException('Unexpected instance profile ' . 'response code: ' . $result['Code']);
228 }
229 return $result;
230 }
231 /**
232 * This functions checks for whether we should fall back to IMDSv1 or not.
233 * If $ec2MetadataV1Disabled is null then we will try to resolve this value from
234 * the following sources:
235 * - From environment: "AWS_EC2_METADATA_V1_DISABLED".
236 * - From config file: aws_ec2_metadata_v1_disabled
237 * - Defaulted to false
238 *
239 * @return bool
240 */
241 private function shouldFallbackToIMDSv1() : bool
242 {
243 $isImdsV1Disabled = \Dudlewebs\WPMCS\s3\Aws\boolean_value($this->ec2MetadataV1Disabled) ?? \Dudlewebs\WPMCS\s3\Aws\boolean_value(ConfigurationResolver::resolve(self::CFG_EC2_METADATA_V1_DISABLED, self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED, 'bool', $this->config)) ?? self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED;
244 return !$isImdsV1Disabled;
245 }
246 /**
247 * Resolves the metadata service endpoint. If the endpoint is not provided
248 * or configured then, the default endpoint, based on the endpoint mode resolved,
249 * will be used.
250 * Example: if endpoint_mode is resolved to be IPv4 and the endpoint is not provided
251 * then, the endpoint to be used will be http://169.254.169.254.
252 *
253 * @return string
254 */
255 private function resolveEndpoint() : string
256 {
257 $endpoint = $this->endpoint;
258 if (\is_null($endpoint)) {
259 $endpoint = ConfigurationResolver::resolve(self::CFG_EC2_METADATA_SERVICE_ENDPOINT, $this->getDefaultEndpoint(), 'string', $this->config);
260 }
261 if (!$this->isValidEndpoint($endpoint)) {
262 throw new CredentialsException('The provided URI "' . $endpoint . '" is invalid, or contains an unsupported host');
263 }
264 if (\substr($endpoint, \strlen($endpoint) - 1) !== '/') {
265 $endpoint = $endpoint . '/';
266 }
267 return $endpoint . 'latest/';
268 }
269 /**
270 * Resolves the default metadata service endpoint.
271 * If endpoint_mode is resolved as IPv4 then:
272 * - endpoint = http://169.254.169.254
273 * If endpoint_mode is resolved as IPv6 then:
274 * - endpoint = http://[fd00:ec2::254]
275 *
276 * @return string
277 */
278 private function getDefaultEndpoint() : string
279 {
280 $endpointMode = $this->resolveEndpointMode();
281 switch ($endpointMode) {
282 case self::ENDPOINT_MODE_IPv4:
283 return self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT;
284 case self::ENDPOINT_MODE_IPv6:
285 return self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT;
286 }
287 throw new CredentialsException("Invalid endpoint mode '{$endpointMode}' resolved");
288 }
289 /**
290 * Resolves the endpoint mode to be considered when resolving the default
291 * metadata service endpoint.
292 *
293 * @return string
294 */
295 private function resolveEndpointMode() : string
296 {
297 $endpointMode = $this->endpointMode;
298 if (\is_null($endpointMode)) {
299 $endpointMode = ConfigurationResolver::resolve(self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE, self::ENDPOINT_MODE_IPv4, 'string', $this->config);
300 }
301 return $endpointMode;
302 }
303 /**
304 * This method checks for whether a provide URI is valid.
305 * @param string $uri this parameter is the uri to do the validation against to.
306 *
307 * @return string|null
308 */
309 private function isValidEndpoint($uri) : bool
310 {
311 // We make sure first the provided uri is a valid URL
312 $isValidURL = \filter_var($uri, \FILTER_VALIDATE_URL) !== \false;
313 if (!$isValidURL) {
314 return \false;
315 }
316 // We make sure that if is a no secure host then it must be a loop back address.
317 $parsedUri = \parse_url($uri);
318 if ($parsedUri['scheme'] !== 'https') {
319 $host = \trim($parsedUri['host'], '[]');
320 return CredentialsUtils::isLoopBackAddress(\gethostbyname($host)) || \in_array($uri, [self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT, self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT]);
321 }
322 return \true;
323 }
324 }
325