| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 | <?phpnamespace 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\Psr7\Request;use GuzzleHttp\Promise\PromiseInterface;use Psr\Http\Message\ResponseInterface;/** * Credential provider that provides credentials from the EC2 metadata service. */class InstanceProfileProvider{    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 CFG_EC2_METADATA_SERVICE_ENDPOINT = 'ec2_metadata_service_endpoint';    const CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE = 'ec2_metadata_service_endpoint_mode';    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';    const DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT = 'http://[fd00:ec2::254]';    /** @var string */    private $profile;    /** @var callable */    private $client;    /** @var int */    private $retries;    /** @var int */    private $attempts;    /** @var float|mixed */    private $timeout;    /** @var bool */    private $secureMode = true;    /** @var bool|null */    private $ec2MetadataV1Disabled;    /** @var string */    private $endpoint;    /** @var string */    private $endpointMode;    /** @var array */    private $config;    /**     * 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.     * - endpoint: Optional for overriding the default endpoint to be used for fetching credentials.     *   The value must contain a valid URI scheme. If the URI scheme is not https, it must     *   resolve to a loopback address.     * - endpoint_mode: Optional for overriding the default endpoint mode (IPv4|IPv6) to be used for     *   resolving the default endpoint.     * - use_aws_shared_config_files: Decides whether the shared config file should be considered when     *   using the ConfigurationResolver::resolve method.     *     * @param array $config Configuration options.     */    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->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');        }        $this->endpointMode = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? null;        $this->config = $config;    }    /**     * Loads instance profile credentials.     *     * @return PromiseInterface     */    public function __invoke($previousCredentials = null)    {        $this->attempts = 0;        return Promise\Coroutine::of(function () use ($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++;            }            // 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())                    );                }                $this->attempts++;            }            // 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:            if (!isset($result)) {                $credentials = $previousCredentials;            } else {                $credentials = new Credentials(                    $result['AccessKeyId'],                    $result['SecretAccessKey'],                    $result['Token'],                    strtotime($result['Expiration'])                );            }            if ($credentials->isExpired()) {                $credentials->extendExpiration();            }            yield $credentials;        });    }    /**     * @param string $url     * @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 = [])    {        $disabled = getenv(self::ENV_DISABLE) ?: false;        if (strcasecmp($disabled, 'true') === 0) {            throw new CredentialsException(                $this->createErrorMessage('EC2 metadata service access disabled')            );        }        $fn = $this->client;        $request = new Request($method, $this->resolveEndpoint() . $url);        $userAgent = 'aws-sdk-php/' . Sdk::VERSION;        if (defined('HHVM_VERSION')) {            $userAgent .= ' HHVM/' . HHVM_VERSION;        }        $userAgent .= ' ' . \Aws\default_user_agent();        $request = $request->withHeader('User-Agent', $userAgent);        foreach ($headers as $key => $value) {            $request = $request->withHeader($key, $value);        }        return $fn($request, ['timeout' => $this->timeout])            ->then(function (ResponseInterface $response) {                return (string) $response->getBody();            })->otherwise(function (array $reason) {                $reason = $reason['exception'];                if ($reason instanceof TransferException) {                    throw $reason;                }                $msg = $reason->getMessage();                throw new CredentialsException(                    $this->createErrorMessage($msg)                );            });    }    private function handleRetryableException(        \Exception $e,        $retryOptions,        $message    ) {        $isRetryable = true;        if (!empty($status = $this->getExceptionStatusCode($e))            && isset($retryOptions['blacklist'])            && in_array($status, $retryOptions['blacklist'])        ) {            $isRetryable = false;        }        if ($isRetryable && $this->attempts < $this->retries) {            sleep((int) pow(1.2, $this->attempts));        } else {            throw new CredentialsException($message);        }    }    private function getExceptionStatusCode(\Exception $e)    {        if (method_exists($e, 'getResponse')            && !empty($e->getResponse())        ) {            return $e->getResponse()->getStatusCode();        }        return null;    }    private function createErrorMessage($previous)    {        return "Error retrieving credentials from the instance profile "            . "metadata service. ({$previous})";    }    private function decodeResult($response)    {        $result = json_decode($response, true);        if (json_last_error() > 0) {            throw new InvalidJsonException();        }        if ($result['Code'] !== 'Success') {            throw new CredentialsException('Unexpected instance profile '                .  'response code: ' . $result['Code']);        }        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 = \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;    }    /**     * Resolves the metadata service endpoint. If the endpoint is not provided     * or configured then, the default endpoint, based on the endpoint mode resolved,     * will be used.     * Example: if endpoint_mode is resolved to be IPv4 and the endpoint is not provided     * then, the endpoint to be used will be http://169.254.169.254.     *     * @return string     */    private function resolveEndpoint(): string    {        $endpoint = $this->endpoint;        if (is_null($endpoint)) {            $endpoint = ConfigurationResolver::resolve(                self::CFG_EC2_METADATA_SERVICE_ENDPOINT,                $this->getDefaultEndpoint(),                'string',                $this->config            );        }        if (!$this->isValidEndpoint($endpoint)) {            throw new CredentialsException('The provided URI "' . $endpoint . '" is invalid, or contains an unsupported host');        }        if (substr($endpoint, strlen($endpoint) - 1) !== '/') {            $endpoint = $endpoint . '/';        }        return $endpoint . 'latest/';    }    /**     * Resolves the default metadata service endpoint.     * If endpoint_mode is resolved as IPv4 then:     * - endpoint = http://169.254.169.254     * If endpoint_mode is resolved as IPv6 then:     * - endpoint = http://[fd00:ec2::254]     *     * @return string     */    private function getDefaultEndpoint(): string    {        $endpointMode = $this->resolveEndpointMode();        switch ($endpointMode) {            case self::ENDPOINT_MODE_IPv4:                return self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT;            case self::ENDPOINT_MODE_IPv6:                return self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT;        }        throw new CredentialsException("Invalid endpoint mode '$endpointMode' resolved");    }    /**     * Resolves the endpoint mode to be considered when resolving the default     * metadata service endpoint.     *     * @return string     */    private function resolveEndpointMode(): string    {        $endpointMode = $this->endpointMode;        if (is_null($endpointMode)) {            $endpointMode = ConfigurationResolver::resolve(                self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE,                    self::ENDPOINT_MODE_IPv4,                'string',                $this->config            );        }        return $endpointMode;    }    /**     * This method checks for whether a provide URI is valid.     * @param string $uri this parameter is the uri to do the validation against to.     *     * @return string|null     */    private function isValidEndpoint(        $uri    ): bool    {        // We make sure first the provided uri is a valid URL        $isValidURL = filter_var($uri, FILTER_VALIDATE_URL) !== false;        if (!$isValidURL) {            return false;        }        // We make sure that if is a no secure host then it must be a loop back address.        $parsedUri = parse_url($uri);        if ($parsedUri['scheme'] !== 'https') {            $host = trim($parsedUri['host'], '[]');            return CredentialsUtils::isLoopBackAddress(gethostbyname($host))                || in_array(                    $uri,                    [self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT, self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT]                );        }        return true;    }}
 |