Skip to content

Commit

Permalink
feat: add option to make IMDSv1 fallback optional (#2810)
Browse files Browse the repository at this point in the history
  • Loading branch information
yenfryherrerafeliz authored Nov 13, 2023
1 parent c462af8 commit 44d2e8d
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 13 deletions.
7 changes: 7 additions & 0 deletions .changes/nextrelease/disable-imdsv1.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
60 changes: 47 additions & 13 deletions src/Credentials/InstanceProfileProvider.php
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<?php
namespace Aws\Credentials;

use Aws\Configuration\ConfigurationResolver;
use Aws\Exception\CredentialsException;
use Aws\Exception\InvalidJsonException;
use Aws\Sdk;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Promise;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\ResponseInterface;
use function Aws\boolean_value;
use function Aws\default_http_handler;

/**
* Credential provider that provides credentials from the EC2 metadata service.
Expand All @@ -19,10 +21,14 @@ class InstanceProfileProvider
const SERVER_URI = 'http://169.254.169.254/latest/';
const CRED_PATH = 'meta-data/iam/security-credentials/';
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 DEFAULT_TIMEOUT = 1.0;
const DEFAULT_RETRIES = 3;
const DEFAULT_TOKEN_TTL_SECONDS = 21600;
const DEFAULT_AWS_EC2_METADATA_V1_DISABLED = false;

/** @var string */
private $profile;
Expand All @@ -42,23 +48,26 @@ class InstanceProfileProvider
/** @var bool */
private $secureMode = true;

/** @var bool|null */
private $ec2MetadataV1Disabled;

/**
* The constructor accepts the following options:
*
* - timeout: Connection timeout, in seconds.
* - profile: Optional EC2 profile name, if known.
* - retries: Optional number of retries to be attempted.
* - ec2_metadata_v1_disabled: Optional for disabling the fallback to IMDSv1.
*
* @param array $config Configuration options.
*/
public function __construct(array $config = [])
{
$this->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;
}

/**
Expand All @@ -79,21 +88,21 @@ 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) {
if ($this->getExceptionStatusCode($e) === 500
&& $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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
145 changes: 145 additions & 0 deletions tests/Credentials/InstanceProfileProviderTest.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php
namespace Aws\Test\Credentials;

use Aws\Configuration\ConfigurationResolver;
use Aws\Credentials\Credentials;
use Aws\Credentials\CredentialsInterface;
use Aws\Credentials\InstanceProfileProvider;
Expand Down Expand Up @@ -1204,4 +1205,148 @@ public function testResetsAttempts()
$provider()->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;
}
}
}

0 comments on commit 44d2e8d

Please sign in to comment.