diff --git a/.changes/nextrelease/imds-updates.json b/.changes/nextrelease/imds-updates.json new file mode 100644 index 0000000000..b656b33dc0 --- /dev/null +++ b/.changes/nextrelease/imds-updates.json @@ -0,0 +1,7 @@ +[ + { + "type": "feature", + "category": "Credentials", + "description": "Adds `InstanceProfileProvider` support for sourcing account id and removes support for imdsv1." + } +] diff --git a/.github/workflows/git-secrets-scan.yml b/.github/workflows/git-secrets-scan.yml index e86bb2929f..0510f6cc66 100644 --- a/.github/workflows/git-secrets-scan.yml +++ b/.github/workflows/git-secrets-scan.yml @@ -53,6 +53,15 @@ jobs: '/"AccountId": "123456789012"/' '/"AccountId": "999999999999"/' '/"AccountId": "012345678901"/' + '/"AccountId": "345678910112"/' + '/"AccountId": "314253647589"/' + '/"AccountId": "234567891011"/' + '/"AccountId": "123456789101"/' + '/"accountId": "314253647589"/' + '/"accountId": "345678910112"/' + '/"accountId": "234567891011"/' + '/"accountId": "123456789101"/' + '/"AccessKeyId": "ASIAIOSFODNN7EXAMPLE"/' '/"AWS::Auth::AccountId": "012345678901"/' '/123456789012/' '/999999999999/' diff --git a/src/Configuration/ConfigurationResolver.php b/src/Configuration/ConfigurationResolver.php index a08595a75d..22fc51685c 100644 --- a/src/Configuration/ConfigurationResolver.php +++ b/src/Configuration/ConfigurationResolver.php @@ -32,9 +32,7 @@ public static function resolve( $config = [] ) { - $iniOptions = isset($config['ini_resolver_options']) - ? $config['ini_resolver_options'] - : []; + $iniOptions = $config['ini_resolver_options'] ?? []; $envValue = self::env($key, $expectedType); if (!is_null($envValue)) { @@ -115,10 +113,7 @@ public static function ini( //TODO change after deprecation $data = @\Aws\parse_ini_file($filename, true, INI_SCANNER_NORMAL); - if (isset($options['section']) - && isset($options['subsection']) - && isset($options['key'])) - { + if (isset($options['section'], $options['subsection'], $options['key'])) { return self::retrieveValueFromIniSubsection( $data, $profile, @@ -128,10 +123,7 @@ public static function ini( ); } - if ($data === false - || !isset($data[$profile]) - || !isset($data[$profile][$key]) - ) { + if (empty($data[$profile][$key]) || $data === false) { return null; } diff --git a/src/Credentials/InstanceProfileProvider.php b/src/Credentials/InstanceProfileProvider.php index c17a564133..92bef835e2 100644 --- a/src/Credentials/InstanceProfileProvider.php +++ b/src/Credentials/InstanceProfileProvider.php @@ -16,18 +16,21 @@ */ class InstanceProfileProvider { - const CRED_PATH = 'meta-data/iam/security-credentials/'; + const LEGACY_PATH = 'meta-data/iam/security-credentials/'; + const EXTENDED_PATH = 'meta-data/iam/security-credentials-extended/'; + const API_VERSION_EXTENDED = 'extended'; + const API_VERSION_LEGACY = 'legacy'; const TOKEN_PATH = 'api/token'; const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED'; const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT'; const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS'; - const CFG_EC2_METADATA_V1_DISABLED = 'ec2_metadata_v1_disabled'; const CFG_EC2_METADATA_SERVICE_ENDPOINT = 'ec2_metadata_service_endpoint'; const CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE = 'ec2_metadata_service_endpoint_mode'; + const CFG_DISABLE_EC2_METADATA = 'disable_ec2_metadata'; + const CFG_EC2_INSTANCE_PROFILE_NAME = 'ec2_instance_profile_name'; const DEFAULT_TIMEOUT = 1.0; const DEFAULT_RETRIES = 3; const DEFAULT_TOKEN_TTL_SECONDS = 21600; - const DEFAULT_AWS_EC2_METADATA_V1_DISABLED = false; const ENDPOINT_MODE_IPv4 = 'IPv4'; const ENDPOINT_MODE_IPv6 = 'IPv6'; const DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT = 'http://169.254.169.254'; @@ -39,6 +42,9 @@ class InstanceProfileProvider /** @var callable */ private $client; + /** @var string */ + private $apiVersion; + /** @var int */ private $retries; @@ -48,12 +54,6 @@ class InstanceProfileProvider /** @var float|mixed */ private $timeout; - /** @var bool */ - private $secureMode = true; - - /** @var bool|null */ - private $ec2MetadataV1Disabled; - /** @var string */ private $endpoint; @@ -82,17 +82,29 @@ class InstanceProfileProvider */ public function __construct(array $config = []) { - $this->timeout = (float) getenv(self::ENV_TIMEOUT) ?: ($config['timeout'] ?? self::DEFAULT_TIMEOUT); - $this->profile = $config['profile'] ?? null; - $this->retries = (int) getenv(self::ENV_RETRIES) ?: ($config['retries'] ?? self::DEFAULT_RETRIES); + $this->timeout = (float) getenv(self::ENV_TIMEOUT) + ?: ($config['timeout'] ?? self::DEFAULT_TIMEOUT); + $this->profile = $config['profile'] + ?? $config[self::CFG_EC2_INSTANCE_PROFILE_NAME] + ?? ConfigurationResolver::resolve( + self::CFG_EC2_INSTANCE_PROFILE_NAME, + null, + 'string', + $this->config + ); + $this->retries = (int) getenv(self::ENV_RETRIES) + ?: ($config['retries'] ?? self::DEFAULT_RETRIES); $this->client = $config['client'] ?? \Aws\default_http_handler(); - $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null; $this->endpoint = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? null; if (!empty($this->endpoint) && !$this->isValidEndpoint($this->endpoint)) { - throw new \InvalidArgumentException('The provided URI "' . $this->endpoint . '" is invalid, or contains an unsupported host'); + throw new \InvalidArgumentException( + 'The provided URI "' + . $this->endpoint . '" is invalid, or contains an unsupported host' + ); } $this->endpointMode = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? null; + $this->apiVersion = self::API_VERSION_EXTENDED; $this->config = $config; } @@ -103,125 +115,25 @@ public function __construct(array $config = []) */ public function __invoke($previousCredentials = null) { - $this->attempts = 0; return Promise\Coroutine::of(function () use ($previousCredentials) { + $token = $this->getToken($previousCredentials); - // Retrieve token or switch out of secure mode - $token = null; - while ($this->secureMode && is_null($token)) { - try { - $token = (yield $this->request( - self::TOKEN_PATH, - 'PUT', - [ - 'x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS - ] - )); - } catch (TransferException $e) { - if ($this->getExceptionStatusCode($e) === 500 - && $previousCredentials instanceof Credentials - ) { - goto generateCredentials; - } elseif ($this->shouldFallbackToIMDSv1() - && (!method_exists($e, 'getResponse') - || empty($e->getResponse()) - || !in_array( - $e->getResponse()->getStatusCode(), - [400, 500, 502, 503, 504] - )) - ) { - $this->secureMode = false; - } else { - $this->handleRetryableException( - $e, - [], - $this->createErrorMessage( - 'Error retrieving metadata token' - ) - ); - } - } - $this->attempts++; + if ($token === false) { + goto generateCredentials; } - // Set token header only for secure mode - $headers = []; - if ($this->secureMode) { - $headers = [ - 'x-aws-ec2-metadata-token' => $token - ]; - } - - // Retrieve profile - while (!$this->profile) { - try { - $this->profile = (yield $this->request( - self::CRED_PATH, - 'GET', - $headers - )); - } catch (TransferException $e) { - // 401 indicates insecure flow not supported, switch to - // attempting secure mode for subsequent calls - if (!empty($this->getExceptionStatusCode($e)) - && $this->getExceptionStatusCode($e) === 401 - ) { - $this->secureMode = true; - } - $this->handleRetryableException( - $e, - [ 'blacklist' => [401, 403] ], - $this->createErrorMessage($e->getMessage()) - ); - } + $headers = [ + 'x-aws-ec2-metadata-token' => $token + ]; - $this->attempts++; + if (!$this->profile) { + $this->profile = $this->getProfile($headers); } - // Retrieve credentials - $result = null; - while ($result == null) { - try { - $json = (yield $this->request( - self::CRED_PATH . $this->profile, - 'GET', - $headers - )); - $result = $this->decodeResult($json); - } catch (InvalidJsonException $e) { - $this->handleRetryableException( - $e, - [ 'blacklist' => [401, 403] ], - $this->createErrorMessage( - 'Invalid JSON response, retries exhausted' - ) - ); - } catch (TransferException $e) { - // 401 indicates insecure flow not supported, switch to - // attempting secure mode for subsequent calls - if (($this->getExceptionStatusCode($e) === 500 - || strpos($e->getMessage(), "cURL error 28") !== false) - && $previousCredentials instanceof Credentials - ) { - goto generateCredentials; - } elseif (!empty($this->getExceptionStatusCode($e)) - && $this->getExceptionStatusCode($e) === 401 - ) { - $this->secureMode = true; - } - $this->handleRetryableException( - $e, - [ 'blacklist' => [401, 403] ], - $this->createErrorMessage($e->getMessage()) - ); - } - $this->attempts++; - } - generateCredentials: + $result = $this->getCredentials($headers, $previousCredentials); - if (!isset($result)) { - $credentials = $previousCredentials; - } else { + generateCredentials: + if (isset($result)) { $credentials = new Credentials( $result['AccessKeyId'], $result['SecretAccessKey'], @@ -230,6 +142,8 @@ public function __invoke($previousCredentials = null) $result['AccountId'] ?? null, CredentialSources::IMDS ); + } else { + $credentials = $previousCredentials; } if ($credentials->isExpired()) { @@ -241,23 +155,136 @@ public function __invoke($previousCredentials = null) } /** - * @param string $url + * @param $previousCredentials + * + * @return string|bool + */ + private function getToken($previousCredentials): string | bool + { + $token = null; + while (is_null($token)) { + try { + $token = $this->request(self::TOKEN_PATH, 'PUT', [ + 'x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS + ])->wait(); + } catch (TransferException $e) { + if ($previousCredentials instanceof Credentials + && $this->getExceptionStatusCode($e) === 500 + ) { + return false; + } + + $this->handleRetryableException( + $e, + [], + $this->createErrorMessage('Error retrieving metadata token') + ); + } + + $this->attempts++; + } + + return $token; + } + + /** + * @param array $headers + * + * @return PromiseInterface + */ + private function getProfile(array $headers): string + { + while (true) { + $path = $this->getMetadataPath(); + + try { + return $this->request($path, 'GET', $headers)->wait(); + } catch (TransferException $e) { + if ($this->apiVersion === self::API_VERSION_EXTENDED + && $this->getExceptionStatusCode($e) === 404 + ) { + $this->apiVersion = self::API_VERSION_LEGACY; + } + + $this->handleRetryableException( + $e, + ['blacklist' => [401, 403]], + $this->createErrorMessage($e->getMessage()) + ); + } + + $this->attempts++; + } + } + + /** + * @param array $headers + * @param $previousCredentials + * + * @return mixed|null + */ + private function getCredentials(array $headers, $previousCredentials): array | null + { + while (true) { + $path = $this->getMetadataPath() . $this->profile; + + try { + $json = $this->request($path, 'GET', $headers)->wait(); + return $this->decodeResult($json); + } catch (InvalidJsonException $e) { + $this->handleRetryableException( + $e, + ['blacklist' => [401, 403]], + $this->createErrorMessage( + 'Invalid JSON response, retries exhausted' + ) + ); + } catch (TransferException $e) { + if ($this->apiVersion === self::API_VERSION_EXTENDED + && $this->getExceptionStatusCode($e) === 404 + ) { + $this->apiVersion = self::API_VERSION_LEGACY; + } + + if ($previousCredentials instanceof Credentials + && ($this->getExceptionStatusCode($e) === 500 + || str_contains($e->getMessage(), "cURL error 28")) + ) { + return null; + } + + $this->handleRetryableException( + $e, + ['blacklist' => [401, 403]], + $this->createErrorMessage($e->getMessage()) + ); + } + + $this->attempts++; + } + } + + /** + * @param string $path * @param string $method * @param array $headers * @return PromiseInterface Returns a promise that is fulfilled with the * body of the response as a string. */ - private function request($url, $method = 'GET', $headers = []) + private function request($path, $method = 'GET', $headers = []) { - $disabled = getenv(self::ENV_DISABLE) ?: false; - if (strcasecmp($disabled, 'true') === 0) { + $disabled = ConfigurationResolver::ini(self::CFG_DISABLE_EC2_METADATA, 'bool') + ?? ConfigurationResolver::env(substr(self::ENV_DISABLE, 4), 'bool') + ?? false; + + if ($disabled) { throw new CredentialsException( $this->createErrorMessage('EC2 metadata service access disabled') ); } $fn = $this->client; - $request = new Request($method, $this->resolveEndpoint() . $url); + $request = new Request($method, $this->resolveEndpoint() . $path); $userAgent = 'aws-sdk-php/' . Sdk::VERSION; if (defined('HHVM_VERSION')) { $userAgent .= ' HHVM/' . HHVM_VERSION; @@ -285,8 +312,8 @@ private function request($url, $method = 'GET', $headers = []) private function handleRetryableException( \Exception $e, - $retryOptions, - $message + $retryOptions, + $message ) { $isRetryable = true; if (!empty($status = $this->getExceptionStatusCode($e)) @@ -295,6 +322,7 @@ private function handleRetryableException( ) { $isRetryable = false; } + if ($isRetryable && $this->attempts < $this->retries) { sleep((int) pow(1.2, $this->attempts)); } else { @@ -334,30 +362,11 @@ private function decodeResult($response) return $result; } - /** - * This functions checks for whether we should fall back to IMDSv1 or not. - * If $ec2MetadataV1Disabled is null then we will try to resolve this value from - * the following sources: - * - From environment: "AWS_EC2_METADATA_V1_DISABLED". - * - From config file: aws_ec2_metadata_v1_disabled - * - Defaulted to false - * - * @return bool - */ - private function shouldFallbackToIMDSv1(): bool + private function getMetadataPath(): string { - $isImdsV1Disabled = \Aws\boolean_value($this->ec2MetadataV1Disabled) - ?? \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; - - return !$isImdsV1Disabled; + return $this->apiVersion === self::API_VERSION_EXTENDED + ? self::EXTENDED_PATH + : self::LEGACY_PATH; } /** @@ -385,8 +394,8 @@ private function resolveEndpoint(): string throw new CredentialsException('The provided URI "' . $endpoint . '" is invalid, or contains an unsupported host'); } - if (substr($endpoint, strlen($endpoint) - 1) !== '/') { - $endpoint = $endpoint . '/'; + if (!str_ends_with($endpoint, '/')) { + $endpoint .= '/'; } return $endpoint . 'latest/'; @@ -426,7 +435,7 @@ private function resolveEndpointMode(): string if (is_null($endpointMode)) { $endpointMode = ConfigurationResolver::resolve( self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE, - self::ENDPOINT_MODE_IPv4, + self::ENDPOINT_MODE_IPv4, 'string', $this->config ); diff --git a/tests/Credentials/CredentialProviderTest.php b/tests/Credentials/CredentialProviderTest.php index fdea20a23d..adb1e0e5b1 100644 --- a/tests/Credentials/CredentialProviderTest.php +++ b/tests/Credentials/CredentialProviderTest.php @@ -2413,11 +2413,15 @@ public function testCredentialsSourceFromIMDS() return Create::promiseFor( new Response(200, [], Utils::streamFor('')) ); - } elseif ($path === '/latest/meta-data/iam/security-credentials/') { + } elseif ($path === '/latest/meta-data/iam/security-credentials/' + || $path === '/latest/meta-data/iam/security-credentials-extended/' + ) { return Create::promiseFor( new Response(200, [], Utils::streamFor('testProfile')) ); - } elseif ($path === '/latest/meta-data/iam/security-credentials/testProfile') { + } elseif ($path === '/latest/meta-data/iam/security-credentials/testProfile' + || $path === '/latest/meta-data/iam/security-credentials-extended/testProfile' + ) { $expiration = time() + 1000; return Create::promiseFor( new Response( diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index f333d2b08c..ecf173c3f7 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -23,17 +23,39 @@ */ class InstanceProfileProviderTest extends TestCase { - static $originalFlag; + public const GET_REQUEST_PATTERN = '#^/latest/meta-data/iam/security-credentials(?:-extended)?(?:/([^/]+))?$#'; + private static array $originalEnv; + + protected function tearDown(): void + { + putenv(InstanceProfileProvider::ENV_DISABLE . '='); + putenv(InstanceProfileProvider::ENV_RETRIES . '='); + } public static function set_up_before_class() { - self::$originalFlag = getenv(InstanceProfileProvider::ENV_DISABLE) ?: ''; + self::$originalEnv = [ + 'home' => getenv('HOME') ?: '', + 'config_file' => getenv(ConfigurationResolver::ENV_CONFIG_FILE) ?: '', + 'disable_flag' => getenv(InstanceProfileProvider::ENV_DISABLE) ?: '', + 'profile_name' => getenv( + 'AWS_' + . strtoupper(InstanceProfileProvider::CFG_EC2_INSTANCE_PROFILE_NAME) + ) ?: '', + ]; + putenv(InstanceProfileProvider::ENV_DISABLE. '=false'); } public static function tear_down_after_class() { - putenv(InstanceProfileProvider::ENV_DISABLE. '=' . self::$originalFlag); + putenv(InstanceProfileProvider::ENV_DISABLE. '=' . self::$originalEnv['disable_flag']); + putenv('HOME' . '=' . self::$originalEnv['home']); + putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . self::$originalEnv['config_file']); + putenv( + 'AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_INSTANCE_PROFILE_NAME) + . '=' . self::$originalEnv['profile_name'] + ); } private function getCredentialArray( @@ -135,106 +157,14 @@ private function getSecureTestClient( return Promise\Create::rejectionFor(['exception' => $exception]); } - switch ($request->getUri()->getPath()) { - case '/latest/meta-data/iam/security-credentials': - case '/latest/meta-data/iam/security-credentials/': - if (isset($responses['get_profile'])) { - return $responses['get_profile'][$getProfileRequests++]; - } - return Promise\Create::promiseFor( - new Response(200, [], Psr7\Utils::streamFor($profile)) - ); - break; - case "/latest/meta-data/iam/security-credentials/{$profile}": - case "/latest/meta-data/iam/security-credentials/{$profile}/": - if (isset($responses['get_creds'])) { - return $responses['get_creds'][$getCredsRequests++]; - } - return Promise\Create::promiseFor( - new Response( - 200, - [], - Psr7\Utils::streamFor( - json_encode(call_user_func_array( - [$this, 'getCredentialArray'], - array_values($creds) - )) - ) - ) - ); - break; + if (!str_ends_with(($path = $request->getUri()->getPath()), '/')) { + $path .= "/"; } - } - - return Promise\Create::rejectionFor([ - 'exception' => new \Exception( - 'Invalid path passed to test server' - ) - ]); - }; - } - /** - * Test client for insecure data flow with no token requirement - * - * @param array $responses - * @param string $profile - * @param array $creds - * @param bool $throwConnectException - * @return \Closure - */ - private function getInsecureTestClient( - $responses = [], - $profile = 'MockProfile', - $creds = ['foo_key', 'baz_secret', 'qux_token', null], - $throwConnectException = false - ) { - $requestClass = $this->getRequestClass(); - $responseClass = $this->getResponseClass(); - $getProfileRequests = 0; - $getCredsRequests = 0; - - return function (RequestInterface $request) use ( - $responses, - $responseClass, - $requestClass, - $profile, - $creds, - $throwConnectException, - &$getProfileRequests, - &$getCredsRequests - ) { - if ($request->getMethod() === 'PUT' - && $request->getUri()->getPath() === '/latest/api/token' - ) { - if ($throwConnectException) { - $exception = new ConnectException( - '404 Not Found', - // Needed for different interfaces in Guzzle V5 & V6 - new $requestClass( - $request->getMethod(), - $request->getUri()->getPath() - ) - ); - } else { - $exception = new RequestException( - '404 Not Found', - // Needed for different interfaces in Guzzle V5 & V6 - new $requestClass( - $request->getMethod(), - $request->getUri()->getPath() - ), - new $responseClass(404) - ); - } - - return Promise\Create::rejectionFor(['exception' => $exception]); - } - if ($request->getMethod() === 'GET') { - switch ($request->getUri()->getPath()) { - case '/latest/meta-data/iam/security-credentials': + switch ($path) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': if (isset($responses['get_profile'])) { return $responses['get_profile'][$getProfileRequests++]; } @@ -243,8 +173,8 @@ private function getInsecureTestClient( ); break; - case "/latest/meta-data/iam/security-credentials/{$profile}": case "/latest/meta-data/iam/security-credentials/{$profile}/": + case "/latest/meta-data/iam/security-credentials-extended/{$profile}/": if (isset($responses['get_creds'])) { return $responses['get_creds'][$getCredsRequests++]; } @@ -255,7 +185,7 @@ private function getInsecureTestClient( Psr7\Utils::streamFor( json_encode(call_user_func_array( [$this, 'getCredentialArray'], - $creds + array_values($creds) )) ) ) @@ -363,12 +293,6 @@ public function successTestCases() $credsObject ], - // Insecure data flow, happy path - [ - $this->getInsecureTestClient([], 'MockProfile', $creds), - $credsObject - ], - // Secure data flow, with retries for request exception [ $this->getSecureTestClient( @@ -394,27 +318,7 @@ public function successTestCases() $creds ), $credsObject, - 6 - ], - - // Insecure data flow, with retries for request exception - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [ - $rejectionThrottleProfile, - $promiseProfile - ], - 'get_creds' => [ - $rejectionThrottleCreds, - $promiseCreds - ], - ], - 'MockProfile', - $creds - ), - $credsObject, - 5 + 4 ], // Secure data flow, with retries for json exception @@ -430,23 +334,7 @@ public function successTestCases() $creds ), $credsObject, - 4 - ], - - // Insecure data flow, with retries for json exception - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [ - $promiseBadJsonCreds, - $promiseCreds - ], - ], - 'MockProfile', - $creds - ), - $credsObject, - 4 + 2 ], // Secure data flow, with retries for ConnectException (Guzzle 7) @@ -475,28 +363,7 @@ public function successTestCases() true ), $credsObject, - 6 - ], - - // Insecure data flow, with retries for ConnectException (Guzzle 7) - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [ - $rejectionThrottleProfile, - $promiseProfile - ], - 'get_creds' => [ - $rejectionThrottleCreds, - $promiseCreds - ], - ], - 'MockProfile', - $creds, - true - ), - $credsObject, - 5 + 4 ], ]; } @@ -585,20 +452,6 @@ public function failureTestCases() ) ], - // Insecure data flow, profile call, non-retryable error - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [$rejectionProfile] - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (401 Unathorized)' - ) - ], - // Secure data flow, profile call, non-retryable error, ConnectException (Guzzle 7) [ $this->getSecureTestClient( @@ -615,22 +468,6 @@ public function failureTestCases() ) ], - // Insecure data flow, profile call, non-retryable error, ConnectException (Guzzle 7) - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [$rejectionProfile] - ], - 'MockProfile', - ['foo_key', 'baz_secret', 'qux_token', null], - true - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (401 Unathorized)' - ) - ], - // Secure data flow, credentials call, non-retryable error [ $this->getSecureTestClient( @@ -645,20 +482,6 @@ public function failureTestCases() ) ], - // Insecure data flow, credentials call, non-retryable error - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [$rejectionCreds] - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (401 Unathorized)' - ) - ], - // Secure data flow, token call, retryable error [ $this->getSecureTestClient( @@ -695,24 +518,6 @@ public function failureTestCases() ) ], - // Insecure data flow, profile call, retryable error - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [ - $rejectionThrottleProfile, - $rejectionThrottleProfile, - $rejectionThrottleProfile, - ], - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (503 ThrottlingException)' - ) - ], - // Secure data flow, credentials call, retryable error [ $this->getSecureTestClient( @@ -731,24 +536,6 @@ public function failureTestCases() ) ], - // Insecure data flow, credentials call, retryable error - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [ - $rejectionThrottleCreds, - $rejectionThrottleCreds, - $rejectionThrottleCreds, - ], - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (503 ThrottlingException)' - ) - ], - // Secure data flow, credentials call, retryable invalid json error [ $this->getSecureTestClient( @@ -766,87 +553,9 @@ public function failureTestCases() . 'metadata service. (Invalid JSON response, retries exhausted)' ) ], - - // Insecure data flow, credentials call, retryable invalid json error - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [ - $promiseBadJsonCreds, - $promiseBadJsonCreds, - $promiseBadJsonCreds - ] - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (Invalid JSON response, retries exhausted)' - ) - ], ]; } - public function testSwitchesBackToSecureModeOn401() - { - $this->expectExceptionMessage("Error retrieving credentials from the instance profile metadata service. (999 Expected Exception)"); - $this->expectException(\Aws\Exception\CredentialsException::class); - $requestClass = $this->getRequestClass(); - $responseClass = $this->getResponseClass(); - $getRequest = new $requestClass('GET', '/latest/meta-data/foo'); - $putRequest = new $requestClass('PUT', '/latest/meta-data/foo'); - $reqNumber = 0; - - $client = function ($request) use ( - &$reqNumber, - $responseClass, - $getRequest, - $putRequest - ) { - $reqNumber++; - if ($request->getMethod() === 'PUT' - && $request->getUri()->getPath() === '/latest/api/token' - ) { - if ($reqNumber === 1) { - return Promise\Create::rejectionFor([ - 'exception' => new RequestException('404 Not Found', - $putRequest, - new $responseClass(404) - ) - ]); - } - - return Promise\Create::rejectionFor([ - 'exception' => new \Exception('999 Expected Exception') - ]); - } - if ($request->getMethod() === 'GET') { - return Promise\Create::rejectionFor([ - 'exception' => new RequestException( - '401 Unauthorized - Valid unexpired token required', - $getRequest, - new $responseClass(401) - ) - ]); - } - }; - - $provider = new InstanceProfileProvider([ - 'client' => $client, - 'retries' => 1, - ]); - - try { - // 1st pass should fall back to insecure mode, then switch back to - // secure mode on hitting the 401 - $provider()->wait(); - $this->fail('Provider should have thrown an exception.'); - } catch (\Exception $e) { - // If secure mode is set, this should hit the PUT request again - $provider()->wait(); - } - } - private function getTestCreds( $result, $profile = null, @@ -903,39 +612,70 @@ public function testDoesNotRequireConfig() new InstanceProfileProvider(); } - /** @doesNotPerformAssertions */ - public function testEnvDisableFlag() + /** + * @param $ini + * @param $env + * + * @return void + * @dataProvider disableFlagProvider + */ + public function testDisableFlag($ini, $env) { - $flag = getenv(InstanceProfileProvider::ENV_DISABLE); + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('EC2 metadata service access disabled'); + + $dir = sys_get_temp_dir() . '/.aws'; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($dir . '/config', $ini); + putenv('HOME=' . dirname($dir)); try { - putenv(InstanceProfileProvider::ENV_DISABLE . '=true'); + putenv(InstanceProfileProvider::ENV_DISABLE . '=' . $env); $t = time() + 1000; $this->getTestCreds( json_encode($this->getCredentialArray('foo', 'baz', null, "@{$t}")) )->wait(); - $this->fail('Did not throw expected CredentialException.'); - } catch (CredentialsException $e) { - if (strstr($e->getMessage(), 'EC2 metadata service access disabled') === false) { - $this->fail('Did not throw expected CredentialException when ' - . 'provider is disabled.'); - } } finally { - putenv(InstanceProfileProvider::ENV_DISABLE . '=' . $flag); + putenv(InstanceProfileProvider::ENV_DISABLE . '=false'); } } + public function disableFlagProvider() + { + return [ + [ + <<getCredentialArray('foo', 'baz', null, "@{$t}")); $responses = [new Response(200, [], Psr7\Utils::streamFor($result))]; - $client = function () use (&$retries, $responses) { - if (0 === $retries--) { + $attempts = 0; + + $client = function () use (&$retries, &$attempts, $responses) { + $attempts++; + if ($attempts > $retries) { return Promise\Create::promiseFor(array_shift($responses)); } @@ -949,7 +689,9 @@ public function testRetriesEnvVarIsUsed() 'client' => $client ]; $provider = new InstanceProfileProvider($args); + $c = $provider()->wait(); + $this->assertEquals(2, $this->getPropertyValue($provider, 'retries')); $this->assertSame('foo', $c->getAccessKeyId()); $this->assertSame('baz', $c->getSecretKey()); $this->assertNull($c->getSecurityToken()); @@ -1023,17 +765,6 @@ public function returnsExpiredCredsProvider() 'MockProfile', $expiredCreds ) - ], - [ - $client = $this->getInsecureTestClient( - [ - 'get_creds' => [ - $promiseCreds - ] - ], - 'MockProfile', - $expiredCreds - ) ] ]; } @@ -1141,26 +872,6 @@ public function imdsUnavailableProvider() ], 'MockProfile' ) - ], - [ - $client = $this->getInsecureTestClient( - [ - 'get_creds' => [ - $credsRejection500 - ] - ], - 'MockProfile' - ) - ], - [ - $client = $this->getInsecureTestClient( - [ - 'get_creds' => [ - $credsRejectionReadTimeout - ] - ], - 'MockProfile' - ) ] ]; } @@ -1185,151 +896,6 @@ public function testResetsAttempts() $this->assertLessThanOrEqual(3, $this->getPropertyValue($provider, 'attempts')); } - /** - * This test checks for disabling IMDSv1 fallback by explicit client config passing. - * - * @return void - */ - public function testIMDSv1DisabledByExplicitConfig() { - $config = [InstanceProfileProvider::CFG_EC2_METADATA_V1_DISABLED => true]; - $wereCredentialsFetched = $this->fetchMockedCredentialsAndAlwaysExpectAToken($config); - - $this->assertTrue($wereCredentialsFetched); - } - - /** - * This test checks for disabling IMDSv1 fallback by setting AWS_EC2_METADATA_V1_DISABLED to true. - * - * @return void - */ - public function testIMDSv1DisabledByEnvironment() { - $ec2MetadataV1Disabled = ConfigurationResolver::env(InstanceProfileProvider::CFG_EC2_METADATA_V1_DISABLED, 'string'); - putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_V1_DISABLED) . '=' . 'true'); - try { - $wereCredentialsFetched = $this->fetchMockedCredentialsAndAlwaysExpectAToken(); - $this->assertTrue($wereCredentialsFetched); - } finally { - putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_V1_DISABLED) . '=' . $ec2MetadataV1Disabled); - } - } - - /** - * This test checks for disabling IMDSv1 fallback by looking into the config file - * for the property aws_ec2_metadata_v1_disabled expected set to true. - * - * @return void - */ - public function testIMDSv1DisabledByConfigFile() { - $currentConfigFile = getenv(ConfigurationResolver::ENV_CONFIG_FILE); - $mockConfigFile = "./mock-config"; - try { - putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $mockConfigFile); - $configContent = "[default]" . "\n" . InstanceProfileProvider::CFG_EC2_METADATA_V1_DISABLED . "=" . "true"; - file_put_contents($mockConfigFile, $configContent); - $wereCredentialsFetched = $this->fetchMockedCredentialsAndAlwaysExpectAToken(); - $this->assertTrue($wereCredentialsFetched); - } finally { - unlink($mockConfigFile); - putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $currentConfigFile); - } - } - - /** - * This test checks for having IMDSv1 fallback enabled by default. - * In this case credentials will not be fetched since it is expected to - * always use the secure mode, which means the assertion will be done against false. - * - * @return void - */ - public function testIMDSv1EnabledByDefault() { - $wereCredentialsFetched = $this->fetchMockedCredentialsAndAlwaysExpectAToken(); - $this->assertFalse($wereCredentialsFetched); - } - - /** - * This function simulates the process for retrieving credential from the instance metadata - * service but always expecting a token, which means that the credentials should be retrieved - * in secure mode. It returns true if credentials were fetched with not exceptions; - * otherwise false will be returned. - * To accomplish this we pass a dummy http handler with the following steps: - * 1 - retrieve the token: - * -- If $firstTokenTry is set to true then it will set $firstTokenTry to false, and - * it will return a 401 error response to make this request to fail. - * --- then, when catching the exception from this failed request, the provider - * will check if it is allowed to switch to insecure mode (IMDSv1). And if so then, - * it will jump to step 2, otherwise step 1: - * -- If $firstTokenTry is set to false then a token will be returned. - * 2 - retrieve profile: - * -- If a valid token was not provided, which in this case it needs to be equal - * to $mockToken, then an exception will be thrown. - * -- If a valid token is provided then, it will jump to step 3. - * 3 - retrieve credentials: - * -- If a valid token was not provided, which in this case it needs to be equal - * to $mockToken, then an exception will be thrown. - * -- If a valid token is provided then, test credentials are returned. - * - * @param array $config the configuration to be passed to the provider. - * - * @return bool - */ - private function fetchMockedCredentialsAndAlwaysExpectAToken($config=[]) { - $TOKEN_HEADER_KEY = 'x-aws-ec2-metadata-token'; - $firstTokenTry = true; - $mockToken = 'MockToken'; - $mockHandler = function (RequestInterface $request) use (&$firstTokenTry, $mockToken, $TOKEN_HEADER_KEY) { - $fnRejectionTokenNotProvided = function () use ($mockToken, $TOKEN_HEADER_KEY, $request) { - return Promise\Create::rejectionFor( - ['exception' => new RequestException("Token with value $mockToken is expected as header $TOKEN_HEADER_KEY", $request, new Response(400))] - ); - }; - if ($request->getMethod() === 'PUT' && $request->getUri()->getPath() === '/latest/api/token') { - if ($firstTokenTry) { - $firstTokenTry = false; - - return Promise\Create::rejectionFor(['exception' => new RequestException("Unexpected error!", $request, new Response(401))]); - } else { - return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor($mockToken))); - } - } elseif ($request->getMethod() === 'GET') { - switch ($request->getUri()->getPath()) { - case '/latest/meta-data/iam/security-credentials/': - if ($mockToken !== ($request->getHeader($TOKEN_HEADER_KEY)[0] ?? '')) { - return $fnRejectionTokenNotProvided(); - } - - return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor('MockProfile'))); - case '/latest/meta-data/iam/security-credentials/MockProfile': - if ($mockToken !== ($request->getHeader($TOKEN_HEADER_KEY)[0] ?? '')) { - return $fnRejectionTokenNotProvided(); - } - - $expiration = time() + 10000; - - return Promise\Create::promiseFor( - new Response( - 200, - [], - Psr7\Utils::streamFor( - json_encode($this->getCredentialArray('foo', 'baz', null, "@$expiration")) - ) - ) - ); - } - } - - return Promise\Create::rejectionFor(['exception' => new \Exception('Unexpected error!')]); - }; - $config['use_aws_shared_config_files'] = true; - $provider = new InstanceProfileProvider(array_merge(($config ?? []), ['client' => $mockHandler])); - try { - $provider()->wait(); - - return true; - } catch (\Exception $ignored) { - return false; - } - } - /** * This test checks for endpoint resolution mode based on the different sources * from which this option can be configured/customized. @@ -1586,8 +1152,10 @@ private function getClientForEndpointTesting(\Closure $assertingFunction): \Clos } elseif ($request->getMethod() === 'GET') { switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor('MockProfile'))); case '/latest/meta-data/iam/security-credentials/MockProfile': + case '/latest/meta-data/iam/security-credentials-extended/MockProfile': $expiration = time() + 10000; return Promise\Create::promiseFor( @@ -1606,7 +1174,7 @@ private function getClientForEndpointTesting(\Closure $assertingFunction): \Clos }; } - public function testResolveCredentialsWithAccountId() + public function testResolvesCredentialsWithAccountId() { $testAccountId = 'foo'; $expiration = time() + 1000; @@ -1616,8 +1184,10 @@ public function testResolveCredentialsWithAccountId() } elseif ($request->getMethod() === 'GET') { switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor('MockProfile'))); case '/latest/meta-data/iam/security-credentials/MockProfile': + case '/latest/meta-data/iam/security-credentials-extended/MockProfile': $jsonResponse = <<assertSame($expiration, $credentials->getExpiration()); $this->assertSame($testAccountId, $credentials->getAccountId()); } + + /** + * @param string $summary + * @param array $config + * @param array $outcomes + * + * @return void + * + * @dataProvider resolutionWithLegacyAndExtendedPathsProvider + */ + public function testResolutionWithLegacyAndExtendedPaths( + string $summary, + array $config, + array $outcomes + ){ + if (isset($config['envVars'])) { + putenv(InstanceProfileProvider::ENV_DISABLE . '=' . 'true'); + } + + $provider = new InstanceProfileProvider($config); + + foreach ($outcomes as $outcome) { + if (in_array($outcome['result'], ['invalid profile', 'no credentials'])) { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Error retrieving credentials from the instance profile metadata service'); + } + + $credentials = $provider()->wait(); + $this->assertInstanceOf(Credentials::class, $credentials); + + if (isset($outcome['accountId'])) { + $this->assertEquals($outcome['accountId'], $credentials->getAccountId()); + } + } + } + + public function resolutionWithLegacyAndExtendedPathsProvider(): \Generator + { + $serialized = json_decode( + file_get_contents(__DIR__ . '/fixtures/instance/imds-test-cases.json'), + true, 512, JSON_THROW_ON_ERROR + ); + + foreach ($serialized as &$case) { + $responses = [ + 'get_profile' => [], + 'get_creds' => [], + ]; + + $profile = $case['config']['ec2InstanceProfileName'] ?? null; + if (!empty($profileName = $case['config']['ec2InstanceProfileName'])) { + $case['config']['ec2_instance_profile_name'] = $profileName; + unset($case['config']['ec2InstanceProfileName']); + } + + if (!empty($case['expectations'])) { + $case['config']['retries'] = count($case['expectations']); + + foreach ($case['expectations'] as $expectation) { + $path = $expectation['get']; + $status = $expectation['response']['status'] ?? 200; + $body = $expectation['response']['body'] ?? ''; + + if (is_array($body)) { + $body = json_encode($body); + } + + if ($status >= 400) { + $response = Promise\Create::rejectionFor([ + 'exception' => new RequestException( + '', + new Psr7\Request('GET', $path), + new Response($status, [], Psr7\Utils::streamFor($body)) + ) + ]); + } else { + $response = Promise\Create::promiseFor( + new Response($status, [], Psr7\Utils::streamFor($body)) + ); + } + + if (preg_match(self::GET_REQUEST_PATTERN, $path, $matches)) { + if (isset($matches[1])) { + $responses['get_creds'][] = $response; + $profile = $profile ?? $matches[1]; + } else { + $responses['get_profile'][] = $response; + } + } + } + $case['config']['client'] = $this->getSecureTestClient($responses, $profile); + } else { + $case['config']['client'] = null; + } + + unset($case['expectations']); + yield $case; + } + } + + /** + * @param string $profileKey + * @param string $ec2InstanceProfileNameKey + * @param string $sharedConfig + * @param string $env + * @param string $expected + * + * @return void + * @dataProvider resolvesProfileConfigInExpectedOrderProvider + */ + public function testResolvesProfileConfigInExpectedOrder( + string|null $profileKey, + string|null $ec2InstanceProfileNameKey, + string|null $sharedConfig, + string $env, + string $expected + ) + { + if ($sharedConfig) { + $dir = sys_get_temp_dir() . '/.aws'; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($dir . '/config', $sharedConfig); + putenv('HOME=' . dirname($dir)); + } + + try { + putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_INSTANCE_PROFILE_NAME) . '=' . $env); + $provider = new InstanceProfileProvider([ + 'profile' => $profileKey, + 'ec2_instance_profile_name' => $ec2InstanceProfileNameKey] + ); + $reflection = new \ReflectionClass($provider); + $property = $reflection->getProperty('profile'); + $property->setAccessible(true); + + $this->assertEquals($expected, (string) $property->getValue($provider)); + } finally { + putenv(InstanceProfileProvider::ENV_DISABLE . '=false'); + } + } + + public function resolvesProfileConfigInExpectedOrderProvider() + { + return [ + [ + 'Profile_Key', + 'foo', + null, + '', + 'Profile_Key' + ], + [ + null, + 'Profile_Name_Key', + null, + '', + 'Profile_Name_Key' + ], + [ + null, + null, + <<getMethod() === 'GET') { switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': return Create::promiseFor(new Response(200, [], Utils::streamFor('MockProfile'))); case '/latest/meta-data/iam/security-credentials/MockProfile': + case '/latest/meta-data/iam/security-credentials-extended/MockProfile': $jsonResponse = <<