From 9e2aaa10aca9571eb36f4e6c9138f582c4ac96d3 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Thu, 2 Nov 2023 06:41:33 -0700 Subject: [PATCH 1/2] feat: add option to make IMDSv1 fallback optional Right now IMDSv1 fallback is enabled by default, but this implementation allows customer to decided whether or not they want this behavior when using the InstanceProfileProvider. Here is how this can be set: - Explicit configuration: $provider = new InstanceProfileProvider(['ec2_metadata_v1_disabled' => true|false]); - Environment variable: AWS_EC2_METADATA_V1_DISABLED set to true or false - Config file. Example: [default] ec2_metadata_v1_disabled=true|false --- .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..0acba06232 --- /dev/null +++ b/.changes/nextrelease/disable-imdsv1.json @@ -0,0 +1,7 @@ +[ + { + "type": "feature", + "category": "InstanceProfileProvider", + "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; + } + } } From 804c43a88e0adaf88244952826a269d179be28a0 Mon Sep 17 00:00:00 2001 From: Sean O'Brien <60306702+stobrien89@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:58:53 -0500 Subject: [PATCH 2/2] Update disable-imdsv1.json updating namespace --- .changes/nextrelease/disable-imdsv1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/nextrelease/disable-imdsv1.json b/.changes/nextrelease/disable-imdsv1.json index 0acba06232..fb530fd47e 100644 --- a/.changes/nextrelease/disable-imdsv1.json +++ b/.changes/nextrelease/disable-imdsv1.json @@ -1,7 +1,7 @@ [ { "type": "feature", - "category": "InstanceProfileProvider", + "category": "Credentials", "description": "This implementation allows disabling IMDSv1 fallback by using environment variables, config file, and explicit client configuration." } ]