diff --git a/.circleci/config.yml b/.circleci/config.yml index 2322b1a..5b5951a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,27 +23,49 @@ jobs: path: /tmp/logs - store_test_results: path: /tmp/logs - bats_ubuntu1604: + bats_ubuntu1604_imds_v1: # We need to run Docker Compose with privileged settings, which isn't supported by CircleCI's Docker executor, so # we have to use the machine executor instead. machine: true steps: - checkout - - run: docker-compose up --exit-code-from bats_ubuntu1604 bats_ubuntu1604 - bats_ubuntu1804: + - run: docker-compose up --exit-code-from bats_ubuntu1604_imds_v1 bats_ubuntu1604_imds_v1 + bats_ubuntu1604_imds_v2: # We need to run Docker Compose with privileged settings, which isn't supported by CircleCI's Docker executor, so # we have to use the machine executor instead. machine: true steps: - checkout - - run: docker-compose up --exit-code-from bats_ubuntu1804 bats_ubuntu1804 - bats_ubuntu2004: + - run: docker-compose up --exit-code-from bats_ubuntu1604_imds_v2 bats_ubuntu1604_imds_v2 + + bats_ubuntu1804_imds_v1: + # We need to run Docker Compose with privileged settings, which isn't supported by CircleCI's Docker executor, so + # we have to use the machine executor instead. + machine: true + steps: + - checkout + - run: docker-compose up --exit-code-from bats_ubuntu1804_imds_v1 bats_ubuntu1804_imds_v1 + bats_ubuntu1804_imds_v2: + # We need to run Docker Compose with privileged settings, which isn't supported by CircleCI's Docker executor, so + # we have to use the machine executor instead. + machine: true + steps: + - checkout + - run: docker-compose up --exit-code-from bats_ubuntu1804_imds_v2 bats_ubuntu1804_imds_v2 + bats_ubuntu2004_imds_v1: + # We need to run Docker Compose with privileged settings, which isn't supported by CircleCI's Docker executor, so + # we have to use the machine executor instead. + machine: true + steps: + - checkout + - run: docker-compose up --exit-code-from bats_ubuntu2004_imds_v1 bats_ubuntu2004_imds_v1 + bats_ubuntu2004_imds_v2: # We need to run Docker Compose with privileged settings, which isn't supported by CircleCI's Docker executor, so # we have to use the machine executor instead. machine: true steps: - checkout - - run: docker-compose up --exit-code-from bats_ubuntu2004 bats_ubuntu2004 + - run: docker-compose up --exit-code-from bats_ubuntu2004_imds_v2 bats_ubuntu2004_imds_v2 workflows: version: 2 @@ -53,6 +75,9 @@ workflows: - integration_test: context: - Gruntwork Admin - - bats_ubuntu1604 - - bats_ubuntu1804 - - bats_ubuntu2004 + - bats_ubuntu1604_imds_v1 + - bats_ubuntu1604_imds_v2 + - bats_ubuntu1804_imds_v1 + - bats_ubuntu1804_imds_v2 + - bats_ubuntu2004_imds_v1 + - bats_ubuntu2004_imds_v2 diff --git a/README.md b/README.md index fb727ad..db8482c 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,17 @@ cp -r bash-commons/modules/bash-commons/src /opt/gruntwork/bash-commons sudo chown -R "my-os-username:my-os-group" /opt/gruntwork/bash-commons ``` +## Instance Metadata Service versions + +`bash-commons` supports both Instance Metadata Service (IMDS) version 1 and 2. Gruntwork and AWS both recommend using version 2 of the Instance Metadata Service whenever possible. Although version 1 is still supported and considered fully secure by AWS, version 2 has been specially hardened against specific threat vectors and is therefore preferable. + +To understand more about Instance Metadata Service version 2 and its features, read [the official AWS documentation on IMDSv2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html). + +There are two ways to specify the version of the Instance Metadata Service that `bash-commons` should use: + +1. Set the environment variable `GRUNTWORK_BASH_COMMONS_IMDS_VERSION` to the version of IMDS that you wish to use. Valid values are either `1` or `2`. +2. Change the value of `default_instance_metadata_version` to either `1` or `2`. + #### Example of `dynamic-ubuntu-wait.sh` usage: You can use the `dynamic-ubuntu-wait.sh` command after you [install bash-commons](#install): diff --git a/docker-compose.yml b/docker-compose.yml index 0ae3ec2..ce5a462 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3" services: shellcheck: build: @@ -8,7 +8,7 @@ services: - ./:/usr/local/src/bash-commons working_dir: /usr/local/src/bash-commons/.circleci command: ./shellcheck.sh - bats_ubuntu1604: + bats_ubuntu1604_imds_v1: build: context: ./ dockerfile: Dockerfile.ubuntu16.04.bats @@ -19,7 +19,34 @@ services: command: bats test # Necessary so we can run a mock EC2 metadata service on port 80 on a special IP privileged: true - bats_ubuntu1804: + # We intentionally omit the GRUNTWORK_BASH_COMMONS_IMDS_VERSION env var here to ensure the default behavior works as expected + bats_ubuntu1604_imds_v2: + build: + context: ./ + dockerfile: Dockerfile.ubuntu16.04.bats + volumes: + # Mount all the files so you have "hot reload" of all changes from the host + - ./:/usr/local/src/bash-commons + working_dir: /usr/local/src/bash-commons + command: bats test + # Necessary so we can run a mock EC2 metadata service on port 80 on a special IP + privileged: true + environment: + # Signal to bash-commons module to use Instance Metadata Service version 2 + - GRUNTWORK_BASH_COMMONS_IMDS_VERSION=2 + bats_ubuntu1804_imds_v1: + build: + context: ./ + dockerfile: Dockerfile.ubuntu18.04.bats + volumes: + # Mount all the files so you have "hot reload" of all changes from the host + - ./:/usr/local/src/bash-commons + working_dir: /usr/local/src/bash-commons + command: bats test + # Necessary so we can run a mock EC2 metadata service on port 80 on a special IP + privileged: true + # We intentionally omit the GRUNTWORK_BASH_COMMONS_IMDS_VERSION env var here to ensure the default behavior works as intended + bats_ubuntu1804_imds_v2: build: context: ./ dockerfile: Dockerfile.ubuntu18.04.bats @@ -30,7 +57,24 @@ services: command: bats test # Necessary so we can run a mock EC2 metadata service on port 80 on a special IP privileged: true - bats_ubuntu2004: + environment: + # Signal to bash-commons module to use Instance Metadata Service version 2 + - GRUNTWORK_BASH_COMMONS_IMDS_VERSION=2 + bats_ubuntu2004_imds_v1: + build: + context: ./ + dockerfile: Dockerfile.ubuntu20.04.bats + volumes: + # Mount all the files so you have "hot reload" of all changes from the host + - ./:/usr/local/src/bash-commons + working_dir: /usr/local/src/bash-commons + command: bats test + # Necessary so we can run a mock EC2 metadata service on port 80 on a special IP + privileged: true + environment: + # Signal to bash-commons module to use Instance Metadata Service version 1 + - GRUNTWORK_BASH_COMMONS_IMDS_VERSION=1 + bats_ubuntu2004_imds_v2: build: context: ./ dockerfile: Dockerfile.ubuntu20.04.bats @@ -41,3 +85,6 @@ services: command: bats test # Necessary so we can run a mock EC2 metadata service on port 80 on a special IP privileged: true + environment: + # Signal to bash-commons module to use Instance Metadata Service version 2 + - GRUNTWORK_BASH_COMMONS_IMDS_VERSION=2 diff --git a/modules/bash-commons/src/aws.sh b/modules/bash-commons/src/aws.sh index 26c5b22..be683e0 100644 --- a/modules/bash-commons/src/aws.sh +++ b/modules/bash-commons/src/aws.sh @@ -3,26 +3,201 @@ # (a) it's more convenient to fetch specific info you need, such as an EC2 Instance's private IP and (b) so you can # replace these helpers with mocks to do local testing or unit testing. # -# See also: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html for info +# See also: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html for info # on the metadata endpoint at 169.254.169.254. +# The AWS EC2 Instance Metadata endpoint +readonly metadata_endpoint="http://169.254.169.254/latest" +# The AWS EC2 Instance Metadata dynamic endpoint +readonly metadata_dynamic_endpoint="http://169.254.169.254/latest/dynamic" +# The AWS EC2 Instance document endpoint +readonly instance_identity_endpoint="http://169.254.169.254/latest/dynamic/instance-identity/document" +# A convenience variable representing 3 hours, for use in requesting a token from the IMDSv2 endpoint +readonly three_hours_in_s=10800 +# A convenience variable representing 6 hours, which is the maximum configurable session duration when requesting +# a token from IMDSv2 +readonly six_hours_in_s=21600 +# By default, we use Instance Metadata service version 1. Although version 2 is preferred, version 1 is "fully secure" according to Amazon. We'll continue defaulting to version 1 as long as we're updating our dependent modules to take advantage of this new functionality in bash-commons. Once we've completed our migration, we will begin defaulting to version 2. Users can always specify the version of the Instance Metadata Service they want bash-commons to consult by setting the environment variable GRUNTWORK_BASH_COMMONS_IMDS_VERSION +default_instance_metadata_version="1" + # shellcheck source=./modules/bash-commons/src/assert.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/assert.sh" # shellcheck source=./modules/bash-commons/src/log.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/log.sh" -# Look up the given path in the EC2 Instance metadata endpoint +# AWS and Gruntwork recommend use of the Instance Metadata Service version 2 whenever possible. Although +# IMDSv1 is still supported and considered fully secure by AWS, IMDSv2 features special hardening against +# specific threat vectors. Read more at: +# https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html +# +# If you prefer to use Instance Metadata service version 2, you can do so by setting the environment variable: +# export GRUNTWORK_BASH_COMMONS_IMDSV="2" +function aws_get_instance_metadata_version_in_use { + using=${GRUNTWORK_BASH_COMMONS_IMDS_VERSION:-$default_instance_metadata_version} + assert_value_in_list "Instance Metadata service version in use" "$using" "1" "2" + echo "$using" +} + +################################################################################## +# Shim functions to support both IMDSv1 and IMDSv2 simultaneously +################################################################################## +# The following functions aim to support backward compatibility with IMDSv1 by +# maintaining the arity of all previous function calls, but using $default_instance_metadata_version +# to determine which implementation's code path to follow + +# This function has been modified to simultaneously support Instance Metadata service versions 1 and 2 +# This is due to the fact that we will need to operate in a split-brain mode while all our dependent +# modules are being updated to use IMDSv2. +# +# Version 1 is the default, but can be overridden by setting: +# env var GRUNTWORK_BASH_COMMONS_IMDSV=2 function aws_lookup_path_in_instance_metadata { local -r path="$1" - curl --silent --show-error --location "http://169.254.169.254/latest/meta-data/$path/" + version_in_use=$(aws_get_instance_metadata_version_in_use) + if [[ "$version_in_use" -eq 1 ]]; then + aws_lookup_path_in_instance_metadata_v1 "$path" + elif [[ "$version_in_use" -eq 2 ]]; then + aws_lookup_path_in_instance_metadata_v2 "$path" + fi } -# Look up the given path in the EC2 Instance dynamic metadata endpoint +# This function has been modified to simultaneously support Instance Metadata service versions 1 and 2 +# This is due to the fact that we will need to operate in a split-brain mode while all our dependent +# modules are being updated to use IMDSv2. +# +# Version 1 is the default, but can be overridden by setting: +# env var GRUNTWORK_BASH_COMMONS_IMDSV=2 function aws_lookup_path_in_instance_dynamic_data { + local -r path="$1" + version_in_use=$(aws_get_instance_metadata_version_in_use) + if [[ "$version_in_use" -eq 1 ]]; then + aws_lookup_path_in_instance_dynamic_data_v1 "$path" + elif [[ "$version_in_use" -eq 2 ]]; then + aws_lookup_path_in_instance_dynamic_data_v2 "$path" + fi +} + +################################################################################## +# Instance Metadata Service Version 1 implementation functions +################################################################################## +# The following functions implement calls to Instance Metadata Service version 1, +# meaning that they do not retrieve or present the tokens returned and expected by +# IMDSv2 + +# This function uses Instance Metadata service version 1. It requests the supplied path from the endpoint, but +# does not use the token-based authorization scheme. +function aws_lookup_path_in_instance_metadata_v1 { + local -r path="$1" + curl --silent --show-error --location "http://169.254.169.254/latest/meta-data/$path/" +} + +# Look up the given path in the EC2 Instance dynamic metadata endpoint using IMDSv1 +function aws_lookup_path_in_instance_dynamic_data_v1 { local -r path="$1" curl --silent --show-error --location "http://169.254.169.254/latest/dynamic/$path/" } +################################################################################## +# Instance Metadata Service Version 2 functions +################################################################################## +# The following functions use IMDSv2, meaning they request and present IMDSv2 +# tokens when making requests to IMDS. + +# This function calls the Instance Metadata Service endpoint version 2 (IMDSv2) which is hardened against certain attack vectors. +# The endpoint returns a token that must be supplied on subsequent requests. This implementation fetches a new token +# for each transaction. See: +# https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/ +# for more information +function ec2_metadata_http_get { + assert_not_empty "path" "$1" + local -r path="$1" + # We allow callers to configure the ttl - if not provided it will default to 6 hours + local ttl="" + ttl=$(configure_imdsv2_ttl "$2") + token=$(ec2_metadata_http_put "$ttl") + curl "$metadata_endpoint/meta-data/$path" -H "X-aws-ec2-metadata-token: $token" \ + --silent --location --fail --show-error +} + +# This function uses Instance Metadata service version 2. It requests the supplied path from the +# dynamic endpoint. +function ec2_metadata_dynamic_http_get { + assert_not_empty "path" "$1" + local -r path="$1" + # We allow callers to configure the ttl - if not provided it will default to 6 hours + local ttl="" + ttl=$(configure_imdsv2_ttl "$2") + token=$(ec2_metadata_http_put "$three_hours_in_s") + curl "$metadata_dynamic_endpoint/$path" -H "X-aws-ec2-metadata-token: $token" \ + --silent --location --fail --show-error +} + +# This function uses Instance Metadata Service version 2. It retrieves a token from the IMDSv2 +# endpoint, which can be presented during subequent requests to the IMDSv2 endpoints. + +# This function accepts a conifgurable TTL for the IMDSv2 token it requests. If a TTL is not supplied, +# this function will default to the maximum IMDSv2 session duration which is 6 hours. +function ec2_metadata_http_put { + # We allow callers to configure the ttl - if not provided it will default to 6 hours + local ttl="" + ttl=$(configure_imdsv2_ttl "$2") + token=$(curl --silent --location --fail --show-error -X PUT "$metadata_endpoint/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: $ttl") + echo "$token" +} + +# This function uses Instance Metadata Service version 2. It retrieves the field of the supplied name +# from the Instance Metadata Service's identity document for the given EC2 instance and returns +# its value. +# +# This function uses Instance Metadata Service version 2. It accepts a configurable TTL +# for the IMDSv2 token it requests, and it returns the token to the caller. If a TTL +# is not supplied, this function will default to the maximum IMDSv2 session duration which is 6 hours. +function ec2_instance_identity_field_get { + local -r field="$1" + local ttl="" + ttl=$(configure_imdsv2_ttl "$2") + token=$(ec2_metadata_http_put "$ttl") + curl "$instance_identity_endpoint" -H "X-aws-ec2-metadata-token: $token" \ + --silent --location --fail --show-error | jq -r ".${field}" +} + +# This is a convenience function for setting a TTL, and falling back to sensible defaults +# when the value is either not supplied or out of bounds. +function configure_imdsv2_ttl { + local ttl="$1" + if [[ -z "$1" ]]; then + ttl="$six_hours_in_s" + elif (( "$1" > "$six_hours_in_s" )); then + log_error "IMDSv2 token ttl maximum is 21600 seconds / 6 hours. Falling back to max session duration of 6 hours." + ttl=21600 + fi + echo "$ttl" +} + +# This function uses Instance Metadata version 2.It requests the supplied path from the endpoint, leveraging +# the token-based authorization scheme. +function aws_lookup_path_in_instance_metadata_v2 { + assert_not_empty "path" "$path" "Must specify a metadata path to request" + ec2_metadata_http_get "$path" +} + +# This function uses Instance Metadata version 2. It requests the specified path from the IMDS dynamic endpont +function aws_lookup_path_in_instance_dynamic_data_v2 { + local -r path="$1" + assert_not_empty "path" "$path" "Must specify a metadata dynamic path to request" + ec2_metadata_dynamic_http_get "$path" +} + +################################################################################## +# IMDS convenience functions +################################################################################## +# The following functions will use either IMDSv1 or IMDSv2, depending on the value +# of $default_instance_metadata_version, which defaults to 1 but can be overridden +# by setting the environment variable: +# export GRUNTWORK_BASH_COMMONS_IMDS_VERSION=2 +# This is because these functions call out to the shim functions that determine which +# underlying implementation (IMDSv1 or IMDSv2) to use + # Get the private IP address for this EC2 Instance function aws_get_instance_private_ip { aws_lookup_path_in_instance_metadata "local-ipv4" @@ -58,6 +233,14 @@ function aws_get_ec2_instance_availability_zone { aws_lookup_path_in_instance_metadata "placement/availability-zone" } +################################################################################## +# Miscellaneous AWS CLI functions +################################################################################## +# The following functions leverage the AWS CLI, so their use of the Instance Metadata Service +# is governed by the version of the AWS CLI that is installed on a given system. +# Note that AWS CLI v2+ uses IMDSv2. Learn more at: +# https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html + # Get the tags for the given instance and region. Returns JSON from the AWS CLI's describe-tags command. function aws_get_instance_tags { local -r instance_id="$1" @@ -233,3 +416,10 @@ function aws_describe_instances_in_asg { aws ec2 describe-instances --region "$aws_region" --filters "Name=tag:aws:autoscaling:groupName,Values=$asg_name" "Name=instance-state-name,Values=pending,running" } + +# Assert that we're currently running on an EC2 instance +function assert_is_ec2_instance { + local token + token=$(ec2_metadata_http_put 1) + [[ -n "$token" ]] +} diff --git a/test/aws-cli.bats b/test/aws-cli.bats index a44eb88..414b646 100644 --- a/test/aws-cli.bats +++ b/test/aws-cli.bats @@ -165,3 +165,26 @@ END_HEREDOC num_instances=$(echo "$output" | jq -r '.Reservations | length') assert_greater_than "$num_instances" 0 } + +@test "configure_imdsv2_ttl_with_minimal_ttl" { + local -r ttl_value=1 + returned_ttl=$(configure_imdsv2_ttl "$ttl_value") + assert_success + + assert_equal "$ttl_value" "$returned_ttl" +} + +@test "configure_imdsv2_ttl_with_maximum_ttl" { + local -r ttl_value=21600 + returned_ttl=$(configure_imdsv2_ttl "$ttl_value") + assert_success + + assert_equal "$ttl_value" "$returned_ttl" +} + +@test "configure_imdsv2_ttl_with_excessively_high_ttl" { + local -r ttl_value=310000 + returned_ttl=$(configure_imdsv2_ttl "$ttl_value") + assert_success + assert_equal "$returned_ttl" "21600" +} diff --git a/test/aws-mock/aws.sh b/test/aws-mock/aws.sh index 89fe67f..b311f32 100644 --- a/test/aws-mock/aws.sh +++ b/test/aws-mock/aws.sh @@ -62,4 +62,4 @@ function aws_describe_instances_in_asg { local readonly aws_region="$2" echo -n "$mock_instances_in_asg" -} \ No newline at end of file +} diff --git a/test/ec2-metadata-mock/ec2-metadata-mock.py b/test/ec2-metadata-mock/ec2-metadata-mock.py index b07beca..ce1e638 100644 --- a/test/ec2-metadata-mock/ec2-metadata-mock.py +++ b/test/ec2-metadata-mock/ec2-metadata-mock.py @@ -9,6 +9,7 @@ import os import logging +from flask import request from flask import Flask app = Flask(__name__) @@ -16,6 +17,10 @@ META_DATA_ENV_VAR_PREFIX = 'meta_data' DYNAMIC_DATA_ENV_VAR_PREFIX = 'dynamic_data' +@app.route("/latest/api/token", methods=['PUT']) +def token(): + return "AWAAAZOnDBwzUdSPGWqQgZ4GLhHnrLIG-P1KLdlJ8Zz6kPbcIRN5lw==", 200 + @app.route("/latest/meta-data/") def meta_data(path): return lookup_path(path, META_DATA_ENV_VAR_PREFIX)