From a21d9e795e2b90ff1cab85b8aaf772a76daec36b Mon Sep 17 00:00:00 2001 From: Sean O'Brien Date: Mon, 28 Apr 2025 17:05:46 -0400 Subject: [PATCH 1/2] feat: add accountid support for imds provider --- imds-test-cases.json | 653 ++++++++++++++++++ imds-test-schema.json | 109 +++ src/Credentials/InstanceProfileProvider.php | 309 ++++----- src/Handler/React/ReactHandler.php | 132 ++++ .../InstanceProfileProviderTest.php | 629 ++++------------- .../fixtures/instance/imds-test-cases.json | 653 ++++++++++++++++++ 6 files changed, 1828 insertions(+), 657 deletions(-) create mode 100644 imds-test-cases.json create mode 100644 imds-test-schema.json create mode 100644 src/Handler/React/ReactHandler.php create mode 100644 tests/Credentials/fixtures/instance/imds-test-cases.json diff --git a/imds-test-cases.json b/imds-test-cases.json new file mode 100644 index 0000000000..6250229952 --- /dev/null +++ b/imds-test-cases.json @@ -0,0 +1,653 @@ +[ + { + "summary": "Test IMDS credentials provider with env vars { AWS_EC2_METADATA_DISABLED=true } returns no credentials", + "config": { + "ec2InstanceProfileName": null, + "envVars": { + "AWS_EC2_METADATA_DISABLED": "true" + } + }, + "expectations": [], + "outcomes": [ + { + "result": "no credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0001" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "123456789101" + }, + { + "result": "credentials", + "accountId": "123456789101" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": "my-profile-0002" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "234567891011" + }, + { + "result": "credentials", + "accountId": "234567891011" + } + ] + }, + { + "summary": "Test IMDS credentials provider when profile is unstable returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "345678910112" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "314253647589" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "345678910112" + }, + { + "result": "credentials", + "accountId": "314253647589" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0004" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0004", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0004", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0005" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0006" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0008" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0008", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0008", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0009" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0010" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0010", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0012" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0012", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0012", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + } +] diff --git a/imds-test-schema.json b/imds-test-schema.json new file mode 100644 index 0000000000..029ff79aab --- /dev/null +++ b/imds-test-schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aws.amazon.com/sdks-and-tools/seps/schemas/imds-v21-tests.schema.json", + "title": "IMDS SEP v2.1 test cases", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "summary": { + "description": "A human-readable description of the test case", + "type": "string" + }, + "config": { + "description": "Client configuration values required for the test case", + "type": "object", + "properties": { + "ec2InstanceProfileName": { + "description": "The IMDS instance profile name to use or null for unspecified", + "type": [ + "string", + "null" + ] + }, + "envVars": { + "description": "The environment variables which should be set or mocked for this test", + "type": "object" + } + }, + "required": [ + "ec2InstanceProfileName" + ] + }, + "expectations": { + "description": "An ordered sequence of expected inputs and outputs to/from IMDS", + "type": "array", + "items": { + "description": "An expected input and output to/from IMDS", + "type": "object", + "properties": { + "get": { + "description": "A URL path that should be GET-requested from the IMDS endpoint", + "type": "string" + }, + "response": { + "description": "The IMDS response to the GET request", + "type": "object", + "properties": { + "status": { + "description": "The HTTP status code", + "enum": [ + 200, + 404 + ] + }, + "body": { + "description": "The response body as either a string or JSON object", + "type": [ + "string", + "object" + ] + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "get", + "response" + ] + } + }, + "outcomes": { + "description": "An ordered sequence of expected outcomes from IMDS credential resolution", + "type": "array", + "minItems": 1, + "items": { + "description": "An expected outcome from IMDS credential resolution", + "type": "object", + "properties": { + "result": { + "description": "The output from the credentials provider", + "enum": [ + "credentials", + "no credentials", + "invalid profile" + ] + }, + "accountId": { + "description": "The account ID returned with the credentials. If this field is not specified, then there should be no account ID.", + "type": "string" + } + }, + "required": [ + "result" + ] + } + } + }, + "required": [ + "summary", + "config", + "expectations", + "outcomes" + ] + } +} \ No newline at end of file diff --git a/src/Credentials/InstanceProfileProvider.php b/src/Credentials/InstanceProfileProvider.php index c17a564133..afdd405fe4 100644 --- a/src/Credentials/InstanceProfileProvider.php +++ b/src/Credentials/InstanceProfileProvider.php @@ -16,18 +16,19 @@ */ class InstanceProfileProvider { - const CRED_PATH = 'meta-data/iam/security-credentials/'; + const LEGACY_PATH = 'meta-data/iam/security-credentials/'; + const EXTENDED_PATH = 'meta-data/iam/security-credentials-extended/'; + const API_VERSION_EXTENDED = 'extended'; + const API_VERSION_LEGACY = 'legacy'; 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'; @@ -39,6 +40,9 @@ class InstanceProfileProvider /** @var callable */ private $client; + /** @var string */ + private $apiVersion; + /** @var int */ private $retries; @@ -48,12 +52,6 @@ class InstanceProfileProvider /** @var float|mixed */ private $timeout; - /** @var bool */ - private $secureMode = true; - - /** @var bool|null */ - private $ec2MetadataV1Disabled; - /** @var string */ private $endpoint; @@ -82,17 +80,22 @@ class InstanceProfileProvider */ 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->timeout = (float) getenv(self::ENV_TIMEOUT) + ?: ($config['timeout'] ?? self::DEFAULT_TIMEOUT); + $this->profile = $config['profile'] ?? $config['ec2_instance_profile_name'] ?? 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'); + 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->apiVersion = self::API_VERSION_EXTENDED; $this->config = $config; } @@ -103,125 +106,25 @@ public function __construct(array $config = []) */ public function __invoke($previousCredentials = null) { - $this->attempts = 0; return Promise\Coroutine::of(function () use ($previousCredentials) { + $token = $this->getToken($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 - ]; + if ($token === false) { + goto generateCredentials; } - // 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()) - ); - } + $headers = [ + 'x-aws-ec2-metadata-token' => $token + ]; - $this->attempts++; + if (!$this->profile) { + $this->profile = $this->getProfile($headers); } - // 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: + $result = $this->getCredentials($headers, $previousCredentials); - if (!isset($result)) { - $credentials = $previousCredentials; - } else { + generateCredentials: + if (isset($result)) { $credentials = new Credentials( $result['AccessKeyId'], $result['SecretAccessKey'], @@ -230,6 +133,8 @@ public function __invoke($previousCredentials = null) $result['AccountId'] ?? null, CredentialSources::IMDS ); + } else { + $credentials = $previousCredentials; } if ($credentials->isExpired()) { @@ -241,13 +146,123 @@ public function __invoke($previousCredentials = null) } /** - * @param string $url + * @param $previousCredentials + * + * @return string|bool + */ + private function getToken($previousCredentials): string | bool + { + $token = null; + while (is_null($token)) { + try { + $token = $this->request(self::TOKEN_PATH, 'PUT', [ + 'x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS + ])->wait(); + } catch (TransferException $e) { + if ($previousCredentials instanceof Credentials + && $this->getExceptionStatusCode($e) === 500 + ) { + return false; + } + + $this->handleRetryableException( + $e, + [], + $this->createErrorMessage('Error retrieving metadata token') + ); + } + + $this->attempts++; + } + + return $token; + } + + /** + * @param array $headers + * + * @return PromiseInterface + */ + private function getProfile(array $headers): string + { + while (true) { + $path = $this->getMetadataPath(); + + try { + return $this->request($path, 'GET', $headers)->wait(); + } catch (TransferException $e) { + if ($this->apiVersion === self::API_VERSION_EXTENDED + && $this->getExceptionStatusCode($e) === 404 + ) { + $this->apiVersion = self::API_VERSION_LEGACY; + } + + $this->handleRetryableException( + $e, + ['blacklist' => [401, 403]], + $this->createErrorMessage($e->getMessage()) + ); + } + + $this->attempts++; + } + } + + /** + * @param array $headers + * @param $previousCredentials + * + * @return mixed|null + */ + private function getCredentials(array $headers, $previousCredentials): array | null + { + while (true) { + $path = $this->getMetadataPath() . $this->profile; + + try { + $json = $this->request($path, 'GET', $headers)->wait(); + return $this->decodeResult($json); + } catch (InvalidJsonException $e) { + $this->handleRetryableException( + $e, + ['blacklist' => [401, 403]], + $this->createErrorMessage( + 'Invalid JSON response, retries exhausted' + ) + ); + } catch (TransferException $e) { + if ($this->apiVersion === self::API_VERSION_EXTENDED + && $this->getExceptionStatusCode($e) === 404 + ) { + $this->apiVersion = self::API_VERSION_LEGACY; + } + + if ($previousCredentials instanceof Credentials + && ($this->getExceptionStatusCode($e) === 500 + || str_contains($e->getMessage(), "cURL error 28")) + ) { + return null; + } + + $this->handleRetryableException( + $e, + ['blacklist' => [401, 403]], + $this->createErrorMessage($e->getMessage()) + ); + } + + $this->attempts++; + } + } + + /** + * @param string $path * @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 = []) + private function request($path, $method = 'GET', $headers = []) { $disabled = getenv(self::ENV_DISABLE) ?: false; if (strcasecmp($disabled, 'true') === 0) { @@ -257,7 +272,7 @@ private function request($url, $method = 'GET', $headers = []) } $fn = $this->client; - $request = new Request($method, $this->resolveEndpoint() . $url); + $request = new Request($method, $this->resolveEndpoint() . $path); $userAgent = 'aws-sdk-php/' . Sdk::VERSION; if (defined('HHVM_VERSION')) { $userAgent .= ' HHVM/' . HHVM_VERSION; @@ -285,8 +300,8 @@ private function request($url, $method = 'GET', $headers = []) private function handleRetryableException( \Exception $e, - $retryOptions, - $message + $retryOptions, + $message ) { $isRetryable = true; if (!empty($status = $this->getExceptionStatusCode($e)) @@ -295,6 +310,7 @@ private function handleRetryableException( ) { $isRetryable = false; } + if ($isRetryable && $this->attempts < $this->retries) { sleep((int) pow(1.2, $this->attempts)); } else { @@ -334,30 +350,11 @@ 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 + private function getMetadataPath(): string { - $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; + return $this->apiVersion === self::API_VERSION_EXTENDED + ? self::EXTENDED_PATH + : self::LEGACY_PATH; } /** @@ -385,8 +382,8 @@ private function resolveEndpoint(): string throw new CredentialsException('The provided URI "' . $endpoint . '" is invalid, or contains an unsupported host'); } - if (substr($endpoint, strlen($endpoint) - 1) !== '/') { - $endpoint = $endpoint . '/'; + if (!str_ends_with($endpoint, '/')) { + $endpoint .= '/'; } return $endpoint . 'latest/'; @@ -426,7 +423,7 @@ private function resolveEndpointMode(): string if (is_null($endpointMode)) { $endpointMode = ConfigurationResolver::resolve( self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE, - self::ENDPOINT_MODE_IPv4, + self::ENDPOINT_MODE_IPv4, 'string', $this->config ); diff --git a/src/Handler/React/ReactHandler.php b/src/Handler/React/ReactHandler.php new file mode 100644 index 0000000000..0028f45d95 --- /dev/null +++ b/src/Handler/React/ReactHandler.php @@ -0,0 +1,132 @@ +loop = $loop ?: Loop::get(); + $this->browser = new Browser($this->loop); + } + + /** + * @param RequestInterface $request + * @param array $options + * + * @return GuzzlePromise + */ + public function __invoke(RequestInterface $request, array $options = []) + { + // Create a promise that will be returned to AWS SDK + $promise = new GuzzlePromise(); + + // Store promise for tracking + $this->promises[] = $promise; + + // Prepare request details + $uri = (string) $request->getUri(); + $method = $request->getMethod(); + $headers = $request->getHeaders(); + $body = (string) $request->getBody(); + + // Add a timer to actually send the request + // This ensures we return the promise first, then send the request + $this->loop->futureTick(function() use ($method, $uri, $headers, $body, $promise) { + // Log that we're sending a request + echo "Sending request to: $uri\n"; + + $this->browser->request($method, $uri, $headers, $body) + ->then( + function($response) use ($promise) { + echo "Request succeeded!\n"; + $promise->resolve($response); + }, + function($error) use ($promise, $uri) { + echo "Request to $uri failed: " . $error->getMessage() . "\n"; + $promise->reject([ + 'exception' => $error, + 'connection_error' => true, + 'response' => null + ]); + } + ); + }); + + // If wait() has been called, make sure the loop is running + if ($this->isWaiting) { + // Ensure loop is running + $this->loop->futureTick(function() { + if (!$this->loop->isRunning()) { + $this->loop->run(); + } + }); + } + + return $promise; + } + + /** + * Run the event loop until all requests complete + */ + public function wait() + { + $this->isWaiting = true; + + // Check if any promises are still pending + $allResolved = true; + foreach ($this->promises as $promise) { + if ($promise->getState() === 'pending') { + $allResolved = false; + break; + } + } + + // Only run the loop if there are pending promises + if (!$allResolved) { + echo "Running event loop to process pending requests...\n"; + $this->loop->run(); + } else { + echo "No pending requests, not running the loop.\n"; + } + } + + /** + * Get the number of pending promises + * + * @return int + */ + public function getPendingCount() + { + $count = 0; + foreach ($this->promises as $promise) { + if ($promise->getState() === 'pending') { + $count++; + } + } + return $count; + } +} diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index f333d2b08c..4a3ab197a9 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -23,8 +23,15 @@ */ class InstanceProfileProviderTest extends TestCase { + public const GET_REQUEST_PATTERN = '#^/latest/meta-data/iam/security-credentials(?:-extended)?(?:/([^/]+))?$#'; static $originalFlag; + protected function tearDown(): void + { + putenv(InstanceProfileProvider::ENV_DISABLE . '='); + putenv(InstanceProfileProvider::ENV_RETRIES . '='); + } + public static function set_up_before_class() { self::$originalFlag = getenv(InstanceProfileProvider::ENV_DISABLE) ?: ''; @@ -138,6 +145,8 @@ private function getSecureTestClient( switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials': case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended': + case '/latest/meta-data/iam/security-credentials-extended/': if (isset($responses['get_profile'])) { return $responses['get_profile'][$getProfileRequests++]; } @@ -148,6 +157,8 @@ private function getSecureTestClient( case "/latest/meta-data/iam/security-credentials/{$profile}": case "/latest/meta-data/iam/security-credentials/{$profile}/": + case "/latest/meta-data/iam/security-credentials-extended/{$profile}": + case "/latest/meta-data/iam/security-credentials-extended/{$profile}/": if (isset($responses['get_creds'])) { return $responses['get_creds'][$getCredsRequests++]; } @@ -175,103 +186,6 @@ private function getSecureTestClient( }; } - /** - * Test client for insecure data flow with no token requirement - * - * @param array $responses - * @param string $profile - * @param array $creds - * @param bool $throwConnectException - * @return \Closure - */ - private function getInsecureTestClient( - $responses = [], - $profile = 'MockProfile', - $creds = ['foo_key', 'baz_secret', 'qux_token', null], - $throwConnectException = false - ) { - $requestClass = $this->getRequestClass(); - $responseClass = $this->getResponseClass(); - $getProfileRequests = 0; - $getCredsRequests = 0; - - return function (RequestInterface $request) use ( - $responses, - $responseClass, - $requestClass, - $profile, - $creds, - $throwConnectException, - &$getProfileRequests, - &$getCredsRequests - ) { - if ($request->getMethod() === 'PUT' - && $request->getUri()->getPath() === '/latest/api/token' - ) { - if ($throwConnectException) { - $exception = new ConnectException( - '404 Not Found', - // Needed for different interfaces in Guzzle V5 & V6 - new $requestClass( - $request->getMethod(), - $request->getUri()->getPath() - ) - ); - } else { - $exception = new RequestException( - '404 Not Found', - // Needed for different interfaces in Guzzle V5 & V6 - new $requestClass( - $request->getMethod(), - $request->getUri()->getPath() - ), - new $responseClass(404) - ); - } - - return Promise\Create::rejectionFor(['exception' => $exception]); - } - if ($request->getMethod() === 'GET') { - switch ($request->getUri()->getPath()) { - case '/latest/meta-data/iam/security-credentials': - case '/latest/meta-data/iam/security-credentials/': - if (isset($responses['get_profile'])) { - return $responses['get_profile'][$getProfileRequests++]; - } - return Promise\Create::promiseFor( - new Response(200, [], Psr7\Utils::streamFor($profile)) - ); - break; - - case "/latest/meta-data/iam/security-credentials/{$profile}": - case "/latest/meta-data/iam/security-credentials/{$profile}/": - if (isset($responses['get_creds'])) { - return $responses['get_creds'][$getCredsRequests++]; - } - return Promise\Create::promiseFor( - new Response( - 200, - [], - Psr7\Utils::streamFor( - json_encode(call_user_func_array( - [$this, 'getCredentialArray'], - $creds - )) - ) - ) - ); - break; - } - } - - return Promise\Create::rejectionFor([ - 'exception' => new \Exception( - 'Invalid path passed to test server' - ) - ]); - }; - } - /** * @dataProvider successTestCases * @@ -363,12 +277,6 @@ public function successTestCases() $credsObject ], - // Insecure data flow, happy path - [ - $this->getInsecureTestClient([], 'MockProfile', $creds), - $credsObject - ], - // Secure data flow, with retries for request exception [ $this->getSecureTestClient( @@ -394,27 +302,7 @@ public function successTestCases() $creds ), $credsObject, - 6 - ], - - // Insecure data flow, with retries for request exception - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [ - $rejectionThrottleProfile, - $promiseProfile - ], - 'get_creds' => [ - $rejectionThrottleCreds, - $promiseCreds - ], - ], - 'MockProfile', - $creds - ), - $credsObject, - 5 + 4 ], // Secure data flow, with retries for json exception @@ -430,23 +318,7 @@ public function successTestCases() $creds ), $credsObject, - 4 - ], - - // Insecure data flow, with retries for json exception - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [ - $promiseBadJsonCreds, - $promiseCreds - ], - ], - 'MockProfile', - $creds - ), - $credsObject, - 4 + 2 ], // Secure data flow, with retries for ConnectException (Guzzle 7) @@ -475,28 +347,7 @@ public function successTestCases() true ), $credsObject, - 6 - ], - - // Insecure data flow, with retries for ConnectException (Guzzle 7) - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [ - $rejectionThrottleProfile, - $promiseProfile - ], - 'get_creds' => [ - $rejectionThrottleCreds, - $promiseCreds - ], - ], - 'MockProfile', - $creds, - true - ), - $credsObject, - 5 + 4 ], ]; } @@ -585,20 +436,6 @@ public function failureTestCases() ) ], - // Insecure data flow, profile call, non-retryable error - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [$rejectionProfile] - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (401 Unathorized)' - ) - ], - // Secure data flow, profile call, non-retryable error, ConnectException (Guzzle 7) [ $this->getSecureTestClient( @@ -615,22 +452,6 @@ public function failureTestCases() ) ], - // Insecure data flow, profile call, non-retryable error, ConnectException (Guzzle 7) - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [$rejectionProfile] - ], - 'MockProfile', - ['foo_key', 'baz_secret', 'qux_token', null], - true - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (401 Unathorized)' - ) - ], - // Secure data flow, credentials call, non-retryable error [ $this->getSecureTestClient( @@ -645,20 +466,6 @@ public function failureTestCases() ) ], - // Insecure data flow, credentials call, non-retryable error - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [$rejectionCreds] - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (401 Unathorized)' - ) - ], - // Secure data flow, token call, retryable error [ $this->getSecureTestClient( @@ -695,24 +502,6 @@ public function failureTestCases() ) ], - // Insecure data flow, profile call, retryable error - [ - $this->getInsecureTestClient( - [ - 'get_profile' => [ - $rejectionThrottleProfile, - $rejectionThrottleProfile, - $rejectionThrottleProfile, - ], - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (503 ThrottlingException)' - ) - ], - // Secure data flow, credentials call, retryable error [ $this->getSecureTestClient( @@ -731,24 +520,6 @@ public function failureTestCases() ) ], - // Insecure data flow, credentials call, retryable error - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [ - $rejectionThrottleCreds, - $rejectionThrottleCreds, - $rejectionThrottleCreds, - ], - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (503 ThrottlingException)' - ) - ], - // Secure data flow, credentials call, retryable invalid json error [ $this->getSecureTestClient( @@ -766,87 +537,9 @@ public function failureTestCases() . 'metadata service. (Invalid JSON response, retries exhausted)' ) ], - - // Insecure data flow, credentials call, retryable invalid json error - [ - $this->getInsecureTestClient( - [ - 'get_creds' => [ - $promiseBadJsonCreds, - $promiseBadJsonCreds, - $promiseBadJsonCreds - ] - ], - 'MockProfile' - ), - new CredentialsException( - 'Error retrieving credentials from the instance profile ' - . 'metadata service. (Invalid JSON response, retries exhausted)' - ) - ], ]; } - public function testSwitchesBackToSecureModeOn401() - { - $this->expectExceptionMessage("Error retrieving credentials from the instance profile metadata service. (999 Expected Exception)"); - $this->expectException(\Aws\Exception\CredentialsException::class); - $requestClass = $this->getRequestClass(); - $responseClass = $this->getResponseClass(); - $getRequest = new $requestClass('GET', '/latest/meta-data/foo'); - $putRequest = new $requestClass('PUT', '/latest/meta-data/foo'); - $reqNumber = 0; - - $client = function ($request) use ( - &$reqNumber, - $responseClass, - $getRequest, - $putRequest - ) { - $reqNumber++; - if ($request->getMethod() === 'PUT' - && $request->getUri()->getPath() === '/latest/api/token' - ) { - if ($reqNumber === 1) { - return Promise\Create::rejectionFor([ - 'exception' => new RequestException('404 Not Found', - $putRequest, - new $responseClass(404) - ) - ]); - } - - return Promise\Create::rejectionFor([ - 'exception' => new \Exception('999 Expected Exception') - ]); - } - if ($request->getMethod() === 'GET') { - return Promise\Create::rejectionFor([ - 'exception' => new RequestException( - '401 Unauthorized - Valid unexpired token required', - $getRequest, - new $responseClass(401) - ) - ]); - } - }; - - $provider = new InstanceProfileProvider([ - 'client' => $client, - 'retries' => 1, - ]); - - try { - // 1st pass should fall back to insecure mode, then switch back to - // secure mode on hitting the 401 - $provider()->wait(); - $this->fail('Provider should have thrown an exception.'); - } catch (\Exception $e) { - // If secure mode is set, this should hit the PUT request again - $provider()->wait(); - } - } - private function getTestCreds( $result, $profile = null, @@ -927,15 +620,18 @@ public function testEnvDisableFlag() public function testRetriesEnvVarIsUsed() { - putenv(InstanceProfileProvider::ENV_RETRIES . '=1'); + putenv(InstanceProfileProvider::ENV_RETRIES . '=2'); $retries = (int) getenv(InstanceProfileProvider::ENV_RETRIES); $t = time() + 1000; $result = json_encode($this->getCredentialArray('foo', 'baz', null, "@{$t}")); $responses = [new Response(200, [], Psr7\Utils::streamFor($result))]; - $client = function () use (&$retries, $responses) { - if (0 === $retries--) { + $attempts = 0; + + $client = function () use (&$retries, &$attempts, $responses) { + $attempts++; + if ($attempts > $retries) { return Promise\Create::promiseFor(array_shift($responses)); } @@ -949,7 +645,9 @@ public function testRetriesEnvVarIsUsed() 'client' => $client ]; $provider = new InstanceProfileProvider($args); + $c = $provider()->wait(); + $this->assertEquals(2, $this->getPropertyValue($provider, 'retries')); $this->assertSame('foo', $c->getAccessKeyId()); $this->assertSame('baz', $c->getSecretKey()); $this->assertNull($c->getSecurityToken()); @@ -1023,17 +721,6 @@ public function returnsExpiredCredsProvider() 'MockProfile', $expiredCreds ) - ], - [ - $client = $this->getInsecureTestClient( - [ - 'get_creds' => [ - $promiseCreds - ] - ], - 'MockProfile', - $expiredCreds - ) ] ]; } @@ -1141,26 +828,6 @@ public function imdsUnavailableProvider() ], 'MockProfile' ) - ], - [ - $client = $this->getInsecureTestClient( - [ - 'get_creds' => [ - $credsRejection500 - ] - ], - 'MockProfile' - ) - ], - [ - $client = $this->getInsecureTestClient( - [ - 'get_creds' => [ - $credsRejectionReadTimeout - ] - ], - 'MockProfile' - ) ] ]; } @@ -1185,151 +852,6 @@ public function testResetsAttempts() $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!')]); - }; - $config['use_aws_shared_config_files'] = true; - $provider = new InstanceProfileProvider(array_merge(($config ?? []), ['client' => $mockHandler])); - try { - $provider()->wait(); - - return true; - } catch (\Exception $ignored) { - return false; - } - } - /** * This test checks for endpoint resolution mode based on the different sources * from which this option can be configured/customized. @@ -1586,8 +1108,10 @@ private function getClientForEndpointTesting(\Closure $assertingFunction): \Clos } elseif ($request->getMethod() === 'GET') { switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor('MockProfile'))); case '/latest/meta-data/iam/security-credentials/MockProfile': + case '/latest/meta-data/iam/security-credentials-extended/MockProfile': $expiration = time() + 10000; return Promise\Create::promiseFor( @@ -1606,7 +1130,7 @@ private function getClientForEndpointTesting(\Closure $assertingFunction): \Clos }; } - public function testResolveCredentialsWithAccountId() + public function testResolvesCredentialsWithAccountId() { $testAccountId = 'foo'; $expiration = time() + 1000; @@ -1616,8 +1140,10 @@ public function testResolveCredentialsWithAccountId() } elseif ($request->getMethod() === 'GET') { switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor('MockProfile'))); case '/latest/meta-data/iam/security-credentials/MockProfile': + case '/latest/meta-data/iam/security-credentials-extended/MockProfile': $jsonResponse = <<assertSame($expiration, $credentials->getExpiration()); $this->assertSame($testAccountId, $credentials->getAccountId()); } + + /** + * @param string $summary + * @param array $config + * @param array $outcomes + * + * @return void + * + * @dataProvider resolutionWithLegacyAndExtendedPathsProvider + */ + public function testResolutionWithLegacyAndExtendedPaths( + string $summary, + array $config, + array $outcomes + ){ + if (isset($config['envVars'])) { + putenv(InstanceProfileProvider::ENV_DISABLE . '=' . 'true'); + } + + $provider = new InstanceProfileProvider($config); + + foreach ($outcomes as $outcome) { + if (in_array($outcome['result'], ['invalid profile', 'no credentials'])) { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Error retrieving credentials from the instance profile metadata service'); + } + + $credentials = $provider()->wait(); + $this->assertInstanceOf(Credentials::class, $credentials); + + if (isset($outcome['accountId'])) { + $this->assertEquals($outcome['accountId'], $credentials->getAccountId()); + } + } + } + + public function resolutionWithLegacyAndExtendedPathsProvider() + { + $serialized = json_decode( + file_get_contents(__DIR__ . '/fixtures/instance/imds-test-cases.json'), + true, 512, JSON_THROW_ON_ERROR + ); + + foreach ($serialized as &$case) { + $responses = [ + 'get_profile' => [], + 'get_creds' => [], + ]; + + $profile = $case['config']['ec2InstanceProfileName'] ?? null; + if (!empty($profileName = $case['config']['ec2InstanceProfileName'])) { + $case['config']['ec2_instance_profile_name'] = $profileName; + unset($case['config']['ec2InstanceProfileName']); + } + + if (!empty($case['expectations'])) { + $case['config']['retries'] = count($case['expectations']); + + foreach ($case['expectations'] as $expectation) { + $path = $expectation['get']; + $status = $expectation['response']['status'] ?? 200; + $body = $expectation['response']['body'] ?? ''; + + if (is_array($body)) { + $body = json_encode($body); + } + + if ($status >= 400) { + $response = Promise\Create::rejectionFor([ + 'exception' => new RequestException( + '', + new Psr7\Request('GET', $path), + new Response($status, [], Psr7\Utils::streamFor($body)) + ) + ]); + } else { + $response = Promise\Create::promiseFor( + new Response($status, [], Psr7\Utils::streamFor($body)) + ); + } + + if (preg_match(self::GET_REQUEST_PATTERN, $path, $matches)) { + if (isset($matches[1])) { + $responses['get_creds'][] = $response; + $profile = $profile ?? $matches[1]; + } else { + $responses['get_profile'][] = $response; + } + } + } + $case['config']['client'] = $this->getSecureTestClient($responses, $profile); + } else { + $case['config']['client'] = null; + } + + unset($case['expectations']); + } + unset($case); //break reference + + return $serialized; + } } diff --git a/tests/Credentials/fixtures/instance/imds-test-cases.json b/tests/Credentials/fixtures/instance/imds-test-cases.json new file mode 100644 index 0000000000..2489cd5384 --- /dev/null +++ b/tests/Credentials/fixtures/instance/imds-test-cases.json @@ -0,0 +1,653 @@ +[ + { + "summary": "Test IMDS credentials provider with env vars { AWS_EC2_METADATA_DISABLED=true } returns no credentials", + "config": { + "ec2InstanceProfileName": null, + "envVars": { + "AWS_EC2_METADATA_DISABLED": "true" + } + }, + "expectations": [], + "outcomes": [ + { + "result": "no credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0001" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "123456789101" + }, + { + "result": "credentials", + "accountId": "123456789101" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": "my-profile-0002" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2055-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "234567891011" + }, + { + "result": "credentials", + "accountId": "234567891011" + } + ] + }, + { + "summary": "Test IMDS credentials provider when profile is unstable returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "345678910112" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "314253647589" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "345678910112" + }, + { + "result": "credentials", + "accountId": "314253647589" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0004" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0004", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0004", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0005" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0006" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0008" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0008", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0008", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0009" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-20T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-20T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0010" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0010", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-21T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-21T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-22T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2095-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2095-03-22T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0012" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0012", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0012", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + } +] From e4a3a9975c0d131fbeded010fb8e09440e51e1c3 Mon Sep 17 00:00:00 2001 From: Sean O'Brien Date: Wed, 7 May 2025 16:49:39 -0400 Subject: [PATCH 2/2] feat: add accountid support; remove imdsv1 support --- .changes/nextrelease/imds-updates.json | 7 + .github/workflows/git-secrets-scan.yml | 9 + imds-test-cases.json | 653 ------------------ imds-test-schema.json | 109 --- src/Configuration/ConfigurationResolver.php | 14 +- src/Credentials/InstanceProfileProvider.php | 22 +- src/Handler/React/ReactHandler.php | 132 ---- tests/Credentials/CredentialProviderTest.php | 8 +- .../InstanceProfileProviderTest.php | 166 ++++- tests/UserAgentMiddlewareTest.php | 2 + 10 files changed, 188 insertions(+), 934 deletions(-) create mode 100644 .changes/nextrelease/imds-updates.json delete mode 100644 imds-test-cases.json delete mode 100644 imds-test-schema.json delete mode 100644 src/Handler/React/ReactHandler.php diff --git a/.changes/nextrelease/imds-updates.json b/.changes/nextrelease/imds-updates.json new file mode 100644 index 0000000000..b656b33dc0 --- /dev/null +++ b/.changes/nextrelease/imds-updates.json @@ -0,0 +1,7 @@ +[ + { + "type": "feature", + "category": "Credentials", + "description": "Adds `InstanceProfileProvider` support for sourcing account id and removes support for imdsv1." + } +] diff --git a/.github/workflows/git-secrets-scan.yml b/.github/workflows/git-secrets-scan.yml index e86bb2929f..0510f6cc66 100644 --- a/.github/workflows/git-secrets-scan.yml +++ b/.github/workflows/git-secrets-scan.yml @@ -53,6 +53,15 @@ jobs: '/"AccountId": "123456789012"/' '/"AccountId": "999999999999"/' '/"AccountId": "012345678901"/' + '/"AccountId": "345678910112"/' + '/"AccountId": "314253647589"/' + '/"AccountId": "234567891011"/' + '/"AccountId": "123456789101"/' + '/"accountId": "314253647589"/' + '/"accountId": "345678910112"/' + '/"accountId": "234567891011"/' + '/"accountId": "123456789101"/' + '/"AccessKeyId": "ASIAIOSFODNN7EXAMPLE"/' '/"AWS::Auth::AccountId": "012345678901"/' '/123456789012/' '/999999999999/' diff --git a/imds-test-cases.json b/imds-test-cases.json deleted file mode 100644 index 6250229952..0000000000 --- a/imds-test-cases.json +++ /dev/null @@ -1,653 +0,0 @@ -[ - { - "summary": "Test IMDS credentials provider with env vars { AWS_EC2_METADATA_DISABLED=true } returns no credentials", - "config": { - "ec2InstanceProfileName": null, - "envVars": { - "AWS_EC2_METADATA_DISABLED": "true" - } - }, - "expectations": [], - "outcomes": [ - { - "result": "no credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider returns valid credentials with account ID", - "config": { - "ec2InstanceProfileName": null - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 200, - "body": "my-profile-0001" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-12T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-12T21:53:17.832308Z", - "UnexpectedElement1": { - "Name": "ignore-me-1" - }, - "AccountId": "123456789101" - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-12T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-12T21:53:17.832308Z", - "UnexpectedElement1": { - "Name": "ignore-me-1" - }, - "AccountId": "123456789101" - } - } - } - ], - "outcomes": [ - { - "result": "credentials", - "accountId": "123456789101" - }, - { - "result": "credentials", - "accountId": "123456789101" - } - ] - }, - { - "summary": "Test IMDS credentials provider with a given profile name returns valid credentials with account ID", - "config": { - "ec2InstanceProfileName": "my-profile-0002" - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-13T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-13T21:53:17.832308Z", - "UnexpectedElement2": { - "Name": "ignore-me-2" - }, - "AccountId": "234567891011" - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-13T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-13T21:53:17.832308Z", - "UnexpectedElement2": { - "Name": "ignore-me-2" - }, - "AccountId": "234567891011" - } - } - } - ], - "outcomes": [ - { - "result": "credentials", - "accountId": "234567891011" - }, - { - "result": "credentials", - "accountId": "234567891011" - } - ] - }, - { - "summary": "Test IMDS credentials provider when profile is unstable returns valid credentials with account ID", - "config": { - "ec2InstanceProfileName": null - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 200, - "body": "my-profile-0003" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-14T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-14T21:53:17.832308Z", - "UnexpectedElement3": { - "Name": "ignore-me-3" - }, - "AccountId": "345678910112" - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 200, - "body": "my-profile-0003-b" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003-b", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-14T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-14T21:53:17.832308Z", - "UnexpectedElement3": { - "Name": "ignore-me-3" - }, - "AccountId": "314253647589" - } - } - } - ], - "outcomes": [ - { - "result": "credentials", - "accountId": "345678910112" - }, - { - "result": "credentials", - "accountId": "314253647589" - } - ] - }, - { - "summary": "Test IMDS credentials provider with a given profile name when profile is invalid throws an error", - "config": { - "ec2InstanceProfileName": "my-profile-0004" - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0004", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0004", - "response": { - "status": 404 - } - } - ], - "outcomes": [ - { - "result": "invalid profile" - } - ] - }, - { - "summary": "Test IMDS credentials provider when account ID is unavailable returns valid credentials", - "config": { - "ec2InstanceProfileName": null - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 200, - "body": "my-profile-0005" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-16T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-16T21:53:17.832308Z", - "UnexpectedElement5": { - "Name": "ignore-me-5" - } - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-16T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-16T21:53:17.832308Z", - "UnexpectedElement5": { - "Name": "ignore-me-5" - } - } - } - } - ], - "outcomes": [ - { - "result": "credentials" - }, - { - "result": "credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable returns valid credentials", - "config": { - "ec2InstanceProfileName": "my-profile-0006" - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-17T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-17T21:53:17.832308Z", - "UnexpectedElement6": { - "Name": "ignore-me-6" - } - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-17T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-17T21:53:17.832308Z", - "UnexpectedElement6": { - "Name": "ignore-me-6" - } - } - } - } - ], - "outcomes": [ - { - "result": "credentials" - }, - { - "result": "credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider when account ID is unavailable when profile is unstable returns valid credentials", - "config": { - "ec2InstanceProfileName": null - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 200, - "body": "my-profile-0007" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-18T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-18T21:53:17.832308Z", - "UnexpectedElement7": { - "Name": "ignore-me-7" - } - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 200, - "body": "my-profile-0007-b" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007-b", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-18T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-18T21:53:17.832308Z", - "UnexpectedElement7": { - "Name": "ignore-me-7" - } - } - } - } - ], - "outcomes": [ - { - "result": "credentials" - }, - { - "result": "credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable when profile is invalid throws an error", - "config": { - "ec2InstanceProfileName": "my-profile-0008" - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0008", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0008", - "response": { - "status": 404 - } - } - ], - "outcomes": [ - { - "result": "invalid profile" - } - ] - }, - { - "summary": "Test IMDS credentials provider against legacy API returns valid credentials", - "config": { - "ec2InstanceProfileName": null - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials", - "response": { - "status": 200, - "body": "my-profile-0009" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-20T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-20T21:53:17.832308Z" - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-20T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-20T21:53:17.832308Z" - } - } - } - ], - "outcomes": [ - { - "result": "credentials" - }, - { - "result": "credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider with a given profile name against legacy API returns valid credentials", - "config": { - "ec2InstanceProfileName": "my-profile-0010" - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0010", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-21T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-21T21:53:17.832308Z" - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-21T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-21T21:53:17.832308Z" - } - } - } - ], - "outcomes": [ - { - "result": "credentials" - }, - { - "result": "credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider against legacy API when profile is unstable returns valid credentials", - "config": { - "ec2InstanceProfileName": null - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials", - "response": { - "status": 200, - "body": "my-profile-0011" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-22T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-22T21:53:17.832308Z" - } - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials", - "response": { - "status": 200, - "body": "my-profile-0011-b" - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0011-b", - "response": { - "status": 200, - "body": { - "Code": "Success", - "LastUpdated": "2025-03-22T20:53:17.832308Z", - "Type": "AWS-HMAC", - "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", - "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", - "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", - "Expiration": "2025-03-22T21:53:17.832308Z" - } - } - } - ], - "outcomes": [ - { - "result": "credentials" - }, - { - "result": "credentials" - } - ] - }, - { - "summary": "Test IMDS credentials provider with a given profile name against legacy API when profile is invalid throws an error", - "config": { - "ec2InstanceProfileName": "my-profile-0012" - }, - "expectations": [ - { - "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0012", - "response": { - "status": 404 - } - }, - { - "get": "/latest/meta-data/iam/security-credentials/my-profile-0012", - "response": { - "status": 404 - } - } - ], - "outcomes": [ - { - "result": "invalid profile" - } - ] - } -] diff --git a/imds-test-schema.json b/imds-test-schema.json deleted file mode 100644 index 029ff79aab..0000000000 --- a/imds-test-schema.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://aws.amazon.com/sdks-and-tools/seps/schemas/imds-v21-tests.schema.json", - "title": "IMDS SEP v2.1 test cases", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "summary": { - "description": "A human-readable description of the test case", - "type": "string" - }, - "config": { - "description": "Client configuration values required for the test case", - "type": "object", - "properties": { - "ec2InstanceProfileName": { - "description": "The IMDS instance profile name to use or null for unspecified", - "type": [ - "string", - "null" - ] - }, - "envVars": { - "description": "The environment variables which should be set or mocked for this test", - "type": "object" - } - }, - "required": [ - "ec2InstanceProfileName" - ] - }, - "expectations": { - "description": "An ordered sequence of expected inputs and outputs to/from IMDS", - "type": "array", - "items": { - "description": "An expected input and output to/from IMDS", - "type": "object", - "properties": { - "get": { - "description": "A URL path that should be GET-requested from the IMDS endpoint", - "type": "string" - }, - "response": { - "description": "The IMDS response to the GET request", - "type": "object", - "properties": { - "status": { - "description": "The HTTP status code", - "enum": [ - 200, - 404 - ] - }, - "body": { - "description": "The response body as either a string or JSON object", - "type": [ - "string", - "object" - ] - } - }, - "required": [ - "status" - ] - } - }, - "required": [ - "get", - "response" - ] - } - }, - "outcomes": { - "description": "An ordered sequence of expected outcomes from IMDS credential resolution", - "type": "array", - "minItems": 1, - "items": { - "description": "An expected outcome from IMDS credential resolution", - "type": "object", - "properties": { - "result": { - "description": "The output from the credentials provider", - "enum": [ - "credentials", - "no credentials", - "invalid profile" - ] - }, - "accountId": { - "description": "The account ID returned with the credentials. If this field is not specified, then there should be no account ID.", - "type": "string" - } - }, - "required": [ - "result" - ] - } - } - }, - "required": [ - "summary", - "config", - "expectations", - "outcomes" - ] - } -} \ No newline at end of file diff --git a/src/Configuration/ConfigurationResolver.php b/src/Configuration/ConfigurationResolver.php index a08595a75d..22fc51685c 100644 --- a/src/Configuration/ConfigurationResolver.php +++ b/src/Configuration/ConfigurationResolver.php @@ -32,9 +32,7 @@ public static function resolve( $config = [] ) { - $iniOptions = isset($config['ini_resolver_options']) - ? $config['ini_resolver_options'] - : []; + $iniOptions = $config['ini_resolver_options'] ?? []; $envValue = self::env($key, $expectedType); if (!is_null($envValue)) { @@ -115,10 +113,7 @@ public static function ini( //TODO change after deprecation $data = @\Aws\parse_ini_file($filename, true, INI_SCANNER_NORMAL); - if (isset($options['section']) - && isset($options['subsection']) - && isset($options['key'])) - { + if (isset($options['section'], $options['subsection'], $options['key'])) { return self::retrieveValueFromIniSubsection( $data, $profile, @@ -128,10 +123,7 @@ public static function ini( ); } - if ($data === false - || !isset($data[$profile]) - || !isset($data[$profile][$key]) - ) { + if (empty($data[$profile][$key]) || $data === false) { return null; } diff --git a/src/Credentials/InstanceProfileProvider.php b/src/Credentials/InstanceProfileProvider.php index afdd405fe4..92bef835e2 100644 --- a/src/Credentials/InstanceProfileProvider.php +++ b/src/Credentials/InstanceProfileProvider.php @@ -26,6 +26,8 @@ class InstanceProfileProvider const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS'; const CFG_EC2_METADATA_SERVICE_ENDPOINT = 'ec2_metadata_service_endpoint'; const CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE = 'ec2_metadata_service_endpoint_mode'; + const CFG_DISABLE_EC2_METADATA = 'disable_ec2_metadata'; + const CFG_EC2_INSTANCE_PROFILE_NAME = 'ec2_instance_profile_name'; const DEFAULT_TIMEOUT = 1.0; const DEFAULT_RETRIES = 3; const DEFAULT_TOKEN_TTL_SECONDS = 21600; @@ -82,7 +84,14 @@ public function __construct(array $config = []) { $this->timeout = (float) getenv(self::ENV_TIMEOUT) ?: ($config['timeout'] ?? self::DEFAULT_TIMEOUT); - $this->profile = $config['profile'] ?? $config['ec2_instance_profile_name'] ?? null; + $this->profile = $config['profile'] + ?? $config[self::CFG_EC2_INSTANCE_PROFILE_NAME] + ?? ConfigurationResolver::resolve( + self::CFG_EC2_INSTANCE_PROFILE_NAME, + null, + 'string', + $this->config + ); $this->retries = (int) getenv(self::ENV_RETRIES) ?: ($config['retries'] ?? self::DEFAULT_RETRIES); $this->client = $config['client'] ?? \Aws\default_http_handler(); @@ -264,8 +273,11 @@ private function getCredentials(array $headers, $previousCredentials): array | n */ private function request($path, $method = 'GET', $headers = []) { - $disabled = getenv(self::ENV_DISABLE) ?: false; - if (strcasecmp($disabled, 'true') === 0) { + $disabled = ConfigurationResolver::ini(self::CFG_DISABLE_EC2_METADATA, 'bool') + ?? ConfigurationResolver::env(substr(self::ENV_DISABLE, 4), 'bool') + ?? false; + + if ($disabled) { throw new CredentialsException( $this->createErrorMessage('EC2 metadata service access disabled') ); @@ -300,8 +312,8 @@ private function request($path, $method = 'GET', $headers = []) private function handleRetryableException( \Exception $e, - $retryOptions, - $message + $retryOptions, + $message ) { $isRetryable = true; if (!empty($status = $this->getExceptionStatusCode($e)) diff --git a/src/Handler/React/ReactHandler.php b/src/Handler/React/ReactHandler.php deleted file mode 100644 index 0028f45d95..0000000000 --- a/src/Handler/React/ReactHandler.php +++ /dev/null @@ -1,132 +0,0 @@ -loop = $loop ?: Loop::get(); - $this->browser = new Browser($this->loop); - } - - /** - * @param RequestInterface $request - * @param array $options - * - * @return GuzzlePromise - */ - public function __invoke(RequestInterface $request, array $options = []) - { - // Create a promise that will be returned to AWS SDK - $promise = new GuzzlePromise(); - - // Store promise for tracking - $this->promises[] = $promise; - - // Prepare request details - $uri = (string) $request->getUri(); - $method = $request->getMethod(); - $headers = $request->getHeaders(); - $body = (string) $request->getBody(); - - // Add a timer to actually send the request - // This ensures we return the promise first, then send the request - $this->loop->futureTick(function() use ($method, $uri, $headers, $body, $promise) { - // Log that we're sending a request - echo "Sending request to: $uri\n"; - - $this->browser->request($method, $uri, $headers, $body) - ->then( - function($response) use ($promise) { - echo "Request succeeded!\n"; - $promise->resolve($response); - }, - function($error) use ($promise, $uri) { - echo "Request to $uri failed: " . $error->getMessage() . "\n"; - $promise->reject([ - 'exception' => $error, - 'connection_error' => true, - 'response' => null - ]); - } - ); - }); - - // If wait() has been called, make sure the loop is running - if ($this->isWaiting) { - // Ensure loop is running - $this->loop->futureTick(function() { - if (!$this->loop->isRunning()) { - $this->loop->run(); - } - }); - } - - return $promise; - } - - /** - * Run the event loop until all requests complete - */ - public function wait() - { - $this->isWaiting = true; - - // Check if any promises are still pending - $allResolved = true; - foreach ($this->promises as $promise) { - if ($promise->getState() === 'pending') { - $allResolved = false; - break; - } - } - - // Only run the loop if there are pending promises - if (!$allResolved) { - echo "Running event loop to process pending requests...\n"; - $this->loop->run(); - } else { - echo "No pending requests, not running the loop.\n"; - } - } - - /** - * Get the number of pending promises - * - * @return int - */ - public function getPendingCount() - { - $count = 0; - foreach ($this->promises as $promise) { - if ($promise->getState() === 'pending') { - $count++; - } - } - return $count; - } -} diff --git a/tests/Credentials/CredentialProviderTest.php b/tests/Credentials/CredentialProviderTest.php index fdea20a23d..adb1e0e5b1 100644 --- a/tests/Credentials/CredentialProviderTest.php +++ b/tests/Credentials/CredentialProviderTest.php @@ -2413,11 +2413,15 @@ public function testCredentialsSourceFromIMDS() return Create::promiseFor( new Response(200, [], Utils::streamFor('')) ); - } elseif ($path === '/latest/meta-data/iam/security-credentials/') { + } elseif ($path === '/latest/meta-data/iam/security-credentials/' + || $path === '/latest/meta-data/iam/security-credentials-extended/' + ) { return Create::promiseFor( new Response(200, [], Utils::streamFor('testProfile')) ); - } elseif ($path === '/latest/meta-data/iam/security-credentials/testProfile') { + } elseif ($path === '/latest/meta-data/iam/security-credentials/testProfile' + || $path === '/latest/meta-data/iam/security-credentials-extended/testProfile' + ) { $expiration = time() + 1000; return Create::promiseFor( new Response( diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index 4a3ab197a9..ecf173c3f7 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -24,7 +24,7 @@ class InstanceProfileProviderTest extends TestCase { public const GET_REQUEST_PATTERN = '#^/latest/meta-data/iam/security-credentials(?:-extended)?(?:/([^/]+))?$#'; - static $originalFlag; + private static array $originalEnv; protected function tearDown(): void { @@ -34,13 +34,28 @@ protected function tearDown(): void public static function set_up_before_class() { - self::$originalFlag = getenv(InstanceProfileProvider::ENV_DISABLE) ?: ''; + self::$originalEnv = [ + 'home' => getenv('HOME') ?: '', + 'config_file' => getenv(ConfigurationResolver::ENV_CONFIG_FILE) ?: '', + 'disable_flag' => getenv(InstanceProfileProvider::ENV_DISABLE) ?: '', + 'profile_name' => getenv( + 'AWS_' + . strtoupper(InstanceProfileProvider::CFG_EC2_INSTANCE_PROFILE_NAME) + ) ?: '', + ]; + putenv(InstanceProfileProvider::ENV_DISABLE. '=false'); } public static function tear_down_after_class() { - putenv(InstanceProfileProvider::ENV_DISABLE. '=' . self::$originalFlag); + putenv(InstanceProfileProvider::ENV_DISABLE. '=' . self::$originalEnv['disable_flag']); + putenv('HOME' . '=' . self::$originalEnv['home']); + putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . self::$originalEnv['config_file']); + putenv( + 'AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_INSTANCE_PROFILE_NAME) + . '=' . self::$originalEnv['profile_name'] + ); } private function getCredentialArray( @@ -142,10 +157,13 @@ private function getSecureTestClient( return Promise\Create::rejectionFor(['exception' => $exception]); } - switch ($request->getUri()->getPath()) { - case '/latest/meta-data/iam/security-credentials': + + if (!str_ends_with(($path = $request->getUri()->getPath()), '/')) { + $path .= "/"; + } + + switch ($path) { case '/latest/meta-data/iam/security-credentials/': - case '/latest/meta-data/iam/security-credentials-extended': case '/latest/meta-data/iam/security-credentials-extended/': if (isset($responses['get_profile'])) { return $responses['get_profile'][$getProfileRequests++]; @@ -155,9 +173,7 @@ private function getSecureTestClient( ); break; - case "/latest/meta-data/iam/security-credentials/{$profile}": case "/latest/meta-data/iam/security-credentials/{$profile}/": - case "/latest/meta-data/iam/security-credentials-extended/{$profile}": case "/latest/meta-data/iam/security-credentials-extended/{$profile}/": if (isset($responses['get_creds'])) { return $responses['get_creds'][$getCredsRequests++]; @@ -596,28 +612,56 @@ public function testDoesNotRequireConfig() new InstanceProfileProvider(); } - /** @doesNotPerformAssertions */ - public function testEnvDisableFlag() + /** + * @param $ini + * @param $env + * + * @return void + * @dataProvider disableFlagProvider + */ + public function testDisableFlag($ini, $env) { - $flag = getenv(InstanceProfileProvider::ENV_DISABLE); + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('EC2 metadata service access disabled'); + + $dir = sys_get_temp_dir() . '/.aws'; + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($dir . '/config', $ini); + putenv('HOME=' . dirname($dir)); try { - putenv(InstanceProfileProvider::ENV_DISABLE . '=true'); + putenv(InstanceProfileProvider::ENV_DISABLE . '=' . $env); $t = time() + 1000; $this->getTestCreds( json_encode($this->getCredentialArray('foo', 'baz', null, "@{$t}")) )->wait(); - $this->fail('Did not throw expected CredentialException.'); - } catch (CredentialsException $e) { - if (strstr($e->getMessage(), 'EC2 metadata service access disabled') === false) { - $this->fail('Did not throw expected CredentialException when ' - . 'provider is disabled.'); - } } finally { - putenv(InstanceProfileProvider::ENV_DISABLE . '=' . $flag); + putenv(InstanceProfileProvider::ENV_DISABLE . '=false'); } } + public function disableFlagProvider() + { + return [ + [ + << $profileKey, + 'ec2_instance_profile_name' => $ec2InstanceProfileNameKey] + ); + $reflection = new \ReflectionClass($provider); + $property = $reflection->getProperty('profile'); + $property->setAccessible(true); + + $this->assertEquals($expected, (string) $property->getValue($provider)); + } finally { + putenv(InstanceProfileProvider::ENV_DISABLE . '=false'); + } + } + + public function resolvesProfileConfigInExpectedOrderProvider() + { + return [ + [ + 'Profile_Key', + 'foo', + null, + '', + 'Profile_Key' + ], + [ + null, + 'Profile_Name_Key', + null, + '', + 'Profile_Name_Key' + ], + [ + null, + null, + <<getMethod() === 'GET') { switch ($request->getUri()->getPath()) { case '/latest/meta-data/iam/security-credentials/': + case '/latest/meta-data/iam/security-credentials-extended/': return Create::promiseFor(new Response(200, [], Utils::streamFor('MockProfile'))); case '/latest/meta-data/iam/security-credentials/MockProfile': + case '/latest/meta-data/iam/security-credentials-extended/MockProfile': $jsonResponse = <<