Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to make IMDSv1 fallback optional #2810

Merged
merged 2 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved

/** @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')
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved
|| 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(
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved
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.
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved
*
* @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);
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved
} 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
yenfryherrerafeliz marked this conversation as resolved.
Show resolved Hide resolved
*/
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;
}
}
}