From 44d2e8dc6a70d3945ed5f70c4c7b743e28b2e0fd Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Mon, 13 Nov 2023 13:59:51 -0500 Subject: [PATCH] feat: add option to make IMDSv1 fallback optional (#2810) --- .changes/nextrelease/disable-imdsv1.json | 7 + src/Credentials/InstanceProfileProvider.php | 60 ++++++-- .../InstanceProfileProviderTest.php | 145 ++++++++++++++++++ 3 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 .changes/nextrelease/disable-imdsv1.json diff --git a/.changes/nextrelease/disable-imdsv1.json b/.changes/nextrelease/disable-imdsv1.json new file mode 100644 index 0000000000..fb530fd47e --- /dev/null +++ b/.changes/nextrelease/disable-imdsv1.json @@ -0,0 +1,7 @@ +[ + { + "type": "feature", + "category": "Credentials", + "description": "This implementation allows disabling IMDSv1 fallback by using environment variables, config file, and explicit client configuration." + } +] diff --git a/src/Credentials/InstanceProfileProvider.php b/src/Credentials/InstanceProfileProvider.php index 0f1fcb126d..67a65bc4a9 100644 --- a/src/Credentials/InstanceProfileProvider.php +++ b/src/Credentials/InstanceProfileProvider.php @@ -1,15 +1,17 @@ timeout = (float) getenv(self::ENV_TIMEOUT) ?: (isset($config['timeout']) ? $config['timeout'] : 1.0); - $this->profile = isset($config['profile']) ? $config['profile'] : null; - $this->retries = (int) getenv(self::ENV_RETRIES) ?: (isset($config['retries']) ? $config['retries'] : 3); - $this->client = isset($config['client']) - ? $config['client'] // internal use only - : \Aws\default_http_handler(); + $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->client = $config['client'] ?? default_http_handler(); + $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null; } /** @@ -79,7 +88,7 @@ public function __invoke($previousCredentials = null) self::TOKEN_PATH, 'PUT', [ - 'x-aws-ec2-metadata-token-ttl-seconds' => 21600 + 'x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS ] )); } catch (TransferException $e) { @@ -87,13 +96,13 @@ public function __invoke($previousCredentials = null) && $previousCredentials instanceof Credentials ) { goto generateCredentials; - } - else if (!method_exists($e, 'getResponse') + } elseif ($this->shouldFallbackToIMDSv1() + && (!method_exists($e, 'getResponse') || empty($e->getResponse()) || !in_array( $e->getResponse()->getStatusCode(), [400, 500, 502, 503, 504] - ) + )) ) { $this->secureMode = false; } else { @@ -169,7 +178,7 @@ public function __invoke($previousCredentials = null) && $previousCredentials instanceof Credentials ) { goto generateCredentials; - } else if (!empty($this->getExceptionStatusCode($e)) + } elseif (!empty($this->getExceptionStatusCode($e)) && $this->getExceptionStatusCode($e) === 401 ) { $this->secureMode = true; @@ -296,4 +305,29 @@ 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 { + $isImdsV1Disabled = boolean_value($this->ec2MetadataV1Disabled) + ?? boolean_value( + ConfigurationResolver::resolve( + self::CFG_EC2_METADATA_V1_DISABLED, + self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED, + 'bool', + ['use_aws_shared_config_files' => true] + ) + ) + ?? self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED; + + return !$isImdsV1Disabled; + } } diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index fc94558956..a9a27c6d91 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -1,6 +1,7 @@ wait(); $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!')]); + }; + $provider = new InstanceProfileProvider(array_merge(($config ?? []), ['client' => $mockHandler])); + try { + $provider()->wait(); + + return true; + } catch (\Exception $ignored) { + return false; + } + } }