Skip to content

Commit 4b7c3b6

Browse files
authored
Add IMDSv2 support to repository-s3 (#117748) (#117817)
The version of the AWS Java SDK we use already magically switches to IMDSv2 if available, but today we cannot claim to support IMDSv2 in Elasticsearch since we have no tests demonstrating that the magic really works for us. In particular, this sort of thing often risks falling foul of some restrictions imposed by the security manager (if not now then maybe in some future release). This commit adds proper support for IMDSv2 by enhancing the test suite to add the missing coverage to avoid any risk of breaking this magical SDK behaviour in future. Closes #105135 Closes ES-9984
1 parent ad92657 commit 4b7c3b6

File tree

9 files changed

+236
-29
lines changed

9 files changed

+236
-29
lines changed

docs/changelog/117748.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 117748
2+
summary: Add IMDSv2 support to `repository-s3`
3+
area: Snapshot/Restore
4+
type: enhancement
5+
issues:
6+
- 105135

docs/reference/snapshot-restore/repository-s3.asciidoc

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ PUT _snapshot/my_s3_repository
3838
The client that you use to connect to S3 has a number of settings available.
3939
The settings have the form `s3.client.CLIENT_NAME.SETTING_NAME`. By default,
4040
`s3` repositories use a client named `default`, but this can be modified using
41-
the <<repository-s3-repository,repository setting>> `client`. For example:
41+
the <<repository-s3-repository,repository setting>> `client`. For example, to
42+
use a client named `my-alternate-client`, register the repository as follows:
4243

4344
[source,console]
4445
----
@@ -69,10 +70,19 @@ bin/elasticsearch-keystore add s3.client.default.secret_key
6970
bin/elasticsearch-keystore add s3.client.default.session_token
7071
----
7172

72-
If instead you want to use the instance role or container role to access S3
73-
then you should leave these settings unset. You can switch from using specific
74-
credentials back to the default of using the instance role or container role by
75-
removing these settings from the keystore as follows:
73+
If you do not configure these settings then {es} will attempt to automatically
74+
obtain credentials from the environment in which it is running:
75+
76+
* Nodes running on an instance in AWS EC2 will attempt to use the EC2 Instance
77+
Metadata Service (IMDS) to obtain instance role credentials. {es} supports
78+
both IMDS version 1 and IMDS version 2.
79+
80+
* Nodes running in a container in AWS ECS and AWS EKS will attempt to obtain
81+
container role credentials similarly.
82+
83+
You can switch from using specific credentials back to the default of using the
84+
instance role or container role by removing these settings from the keystore as
85+
follows:
7686

7787
[source,sh]
7888
----
@@ -82,20 +92,14 @@ bin/elasticsearch-keystore remove s3.client.default.secret_key
8292
bin/elasticsearch-keystore remove s3.client.default.session_token
8393
----
8494

85-
*All* client secure settings of this repository type are
86-
{ref}/secure-settings.html#reloadable-secure-settings[reloadable].
87-
You can define these settings before the node is started,
88-
or call the <<cluster-nodes-reload-secure-settings,Nodes reload secure settings API>>
89-
after the settings are defined to apply them to a running node.
90-
91-
After you reload the settings, the internal `s3` clients, used to transfer the snapshot
92-
contents, will utilize the latest settings from the keystore. Any existing `s3`
93-
repositories, as well as any newly created ones, will pick up the new values
94-
stored in the keystore.
95-
96-
NOTE: In-progress snapshot/restore tasks will not be preempted by a *reload* of
97-
the client's secure settings. The task will complete using the client as it was
98-
built when the operation started.
95+
Define the relevant secure settings in each node's keystore before starting the
96+
node. The secure settings described here are all
97+
{ref}/secure-settings.html#reloadable-secure-settings[reloadable] so you may
98+
update the keystore contents on each node while the node is running and then
99+
call the <<cluster-nodes-reload-secure-settings,Nodes reload secure settings
100+
API>> to apply the updated settings to the nodes in the cluster. After this API
101+
completes, {es} will use the updated setting values for all future snapshot
102+
operations, but ongoing operations may continue to use older setting values.
99103

100104
The following list contains the available client settings. Those that must be
101105
stored in the keystore are marked as "secure" and are *reloadable*; the other

modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3EcsCredentialsRestIT.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
package org.elasticsearch.repositories.s3;
1111

1212
import fixture.aws.imds.Ec2ImdsHttpFixture;
13+
import fixture.aws.imds.Ec2ImdsVersion;
1314
import fixture.s3.DynamicS3Credentials;
1415
import fixture.s3.S3HttpFixture;
1516

@@ -36,6 +37,7 @@ public class RepositoryS3EcsCredentialsRestIT extends AbstractRepositoryS3RestTe
3637
private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials();
3738

3839
private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(
40+
Ec2ImdsVersion.V1,
3941
dynamicS3Credentials::addValidCredentials,
4042
Set.of("/ecs_credentials_endpoint")
4143
);

modules/repository-s3/src/javaRestTest/java/org/elasticsearch/repositories/s3/RepositoryS3ImdsV1CredentialsRestIT.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
package org.elasticsearch.repositories.s3;
1111

1212
import fixture.aws.imds.Ec2ImdsHttpFixture;
13+
import fixture.aws.imds.Ec2ImdsVersion;
1314
import fixture.s3.DynamicS3Credentials;
1415
import fixture.s3.S3HttpFixture;
1516

@@ -36,6 +37,7 @@ public class RepositoryS3ImdsV1CredentialsRestIT extends AbstractRepositoryS3Res
3637
private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials();
3738

3839
private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(
40+
Ec2ImdsVersion.V1,
3941
dynamicS3Credentials::addValidCredentials,
4042
Set.of()
4143
);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package org.elasticsearch.repositories.s3;
11+
12+
import fixture.aws.imds.Ec2ImdsHttpFixture;
13+
import fixture.aws.imds.Ec2ImdsVersion;
14+
import fixture.s3.DynamicS3Credentials;
15+
import fixture.s3.S3HttpFixture;
16+
17+
import com.carrotsearch.randomizedtesting.annotations.ThreadLeakFilters;
18+
import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
19+
20+
import org.elasticsearch.test.cluster.ElasticsearchCluster;
21+
import org.elasticsearch.test.fixtures.testcontainers.TestContainersThreadFilter;
22+
import org.junit.ClassRule;
23+
import org.junit.rules.RuleChain;
24+
import org.junit.rules.TestRule;
25+
26+
import java.util.Set;
27+
28+
@ThreadLeakFilters(filters = { TestContainersThreadFilter.class })
29+
@ThreadLeakScope(ThreadLeakScope.Scope.NONE) // https://github.com/elastic/elasticsearch/issues/102482
30+
public class RepositoryS3ImdsV2CredentialsRestIT extends AbstractRepositoryS3RestTestCase {
31+
32+
private static final String PREFIX = getIdentifierPrefix("RepositoryS3ImdsV2CredentialsRestIT");
33+
private static final String BUCKET = PREFIX + "bucket";
34+
private static final String BASE_PATH = PREFIX + "base_path";
35+
private static final String CLIENT = "imdsv2_credentials_client";
36+
37+
private static final DynamicS3Credentials dynamicS3Credentials = new DynamicS3Credentials();
38+
39+
private static final Ec2ImdsHttpFixture ec2ImdsHttpFixture = new Ec2ImdsHttpFixture(
40+
Ec2ImdsVersion.V2,
41+
dynamicS3Credentials::addValidCredentials,
42+
Set.of()
43+
);
44+
45+
private static final S3HttpFixture s3Fixture = new S3HttpFixture(true, BUCKET, BASE_PATH, dynamicS3Credentials::isAuthorized);
46+
47+
public static ElasticsearchCluster cluster = ElasticsearchCluster.local()
48+
.module("repository-s3")
49+
.setting("s3.client." + CLIENT + ".endpoint", s3Fixture::getAddress)
50+
.systemProperty("com.amazonaws.sdk.ec2MetadataServiceEndpointOverride", ec2ImdsHttpFixture::getAddress)
51+
.build();
52+
53+
@ClassRule
54+
public static TestRule ruleChain = RuleChain.outerRule(ec2ImdsHttpFixture).around(s3Fixture).around(cluster);
55+
56+
@Override
57+
protected String getTestRestCluster() {
58+
return cluster.getHttpAddresses();
59+
}
60+
61+
@Override
62+
protected String getBucketName() {
63+
return BUCKET;
64+
}
65+
66+
@Override
67+
protected String getBasePath() {
68+
return BASE_PATH;
69+
}
70+
71+
@Override
72+
protected String getClientName() {
73+
return CLIENT;
74+
}
75+
}

test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpFixture.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,22 @@ public class Ec2ImdsHttpFixture extends ExternalResource {
2424

2525
private HttpServer server;
2626

27+
private final Ec2ImdsVersion ec2ImdsVersion;
2728
private final BiConsumer<String, String> newCredentialsConsumer;
2829
private final Set<String> alternativeCredentialsEndpoints;
2930

30-
public Ec2ImdsHttpFixture(BiConsumer<String, String> newCredentialsConsumer, Set<String> alternativeCredentialsEndpoints) {
31+
public Ec2ImdsHttpFixture(
32+
Ec2ImdsVersion ec2ImdsVersion,
33+
BiConsumer<String, String> newCredentialsConsumer,
34+
Set<String> alternativeCredentialsEndpoints
35+
) {
36+
this.ec2ImdsVersion = Objects.requireNonNull(ec2ImdsVersion);
3137
this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer);
3238
this.alternativeCredentialsEndpoints = Objects.requireNonNull(alternativeCredentialsEndpoints);
3339
}
3440

3541
protected HttpHandler createHandler() {
36-
return new Ec2ImdsHttpHandler(newCredentialsConsumer, alternativeCredentialsEndpoints);
42+
return new Ec2ImdsHttpHandler(ec2ImdsVersion, newCredentialsConsumer, alternativeCredentialsEndpoints);
3743
}
3844

3945
public String getAddress() {

test/fixtures/ec2-imds-fixture/src/main/java/fixture/aws/imds/Ec2ImdsHttpHandler.java

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,18 @@ public class Ec2ImdsHttpHandler implements HttpHandler {
3838

3939
private static final String IMDS_SECURITY_CREDENTIALS_PATH = "/latest/meta-data/iam/security-credentials/";
4040

41+
private final Ec2ImdsVersion ec2ImdsVersion;
42+
private final Set<String> validImdsTokens = ConcurrentCollections.newConcurrentSet();
43+
4144
private final BiConsumer<String, String> newCredentialsConsumer;
4245
private final Set<String> validCredentialsEndpoints = ConcurrentCollections.newConcurrentSet();
4346

44-
public Ec2ImdsHttpHandler(BiConsumer<String, String> newCredentialsConsumer, Collection<String> alternativeCredentialsEndpoints) {
47+
public Ec2ImdsHttpHandler(
48+
Ec2ImdsVersion ec2ImdsVersion,
49+
BiConsumer<String, String> newCredentialsConsumer,
50+
Collection<String> alternativeCredentialsEndpoints
51+
) {
52+
this.ec2ImdsVersion = Objects.requireNonNull(ec2ImdsVersion);
4553
this.newCredentialsConsumer = Objects.requireNonNull(newCredentialsConsumer);
4654
this.validCredentialsEndpoints.addAll(alternativeCredentialsEndpoints);
4755
}
@@ -55,11 +63,32 @@ public void handle(final HttpExchange exchange) throws IOException {
5563
final var requestMethod = exchange.getRequestMethod();
5664

5765
if ("PUT".equals(requestMethod) && "/latest/api/token".equals(path)) {
58-
// Reject IMDSv2 probe
59-
exchange.sendResponseHeaders(RestStatus.METHOD_NOT_ALLOWED.getStatus(), -1);
66+
switch (ec2ImdsVersion) {
67+
case V1 -> exchange.sendResponseHeaders(RestStatus.METHOD_NOT_ALLOWED.getStatus(), -1);
68+
case V2 -> {
69+
final var token = randomSecretKey();
70+
validImdsTokens.add(token);
71+
final var responseBody = token.getBytes(StandardCharsets.UTF_8);
72+
exchange.getResponseHeaders().add("Content-Type", "text/plain");
73+
exchange.sendResponseHeaders(RestStatus.OK.getStatus(), responseBody.length);
74+
exchange.getResponseBody().write(responseBody);
75+
}
76+
}
6077
return;
6178
}
6279

80+
if (ec2ImdsVersion == Ec2ImdsVersion.V2) {
81+
final var token = exchange.getRequestHeaders().getFirst("X-aws-ec2-metadata-token");
82+
if (token == null) {
83+
exchange.sendResponseHeaders(RestStatus.UNAUTHORIZED.getStatus(), -1);
84+
return;
85+
}
86+
if (validImdsTokens.contains(token) == false) {
87+
exchange.sendResponseHeaders(RestStatus.FORBIDDEN.getStatus(), -1);
88+
return;
89+
}
90+
}
91+
6392
if ("GET".equals(requestMethod)) {
6493
if (path.equals(IMDS_SECURITY_CREDENTIALS_PATH)) {
6594
final var profileName = randomIdentifier();
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
package fixture.aws.imds;
11+
12+
/**
13+
* Represents the IMDS protocol version simulated by the {@link Ec2ImdsHttpHandler}.
14+
*/
15+
public enum Ec2ImdsVersion {
16+
/**
17+
* Classic V1 behavior: plain {@code GET} requests, no tokens.
18+
*/
19+
V1,
20+
21+
/**
22+
* Newer V2 behavior: {@code GET} requests must include a {@code X-aws-ec2-metadata-token} header providing a token previously obtained
23+
* by calling {@code PUT /latest/api/token}.
24+
*/
25+
V2
26+
}

test/fixtures/ec2-imds-fixture/src/test/java/fixture/aws/imds/Ec2ImdsHttpHandlerTests.java

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.elasticsearch.common.bytes.BytesReference;
2020
import org.elasticsearch.common.io.stream.BytesStreamOutput;
2121
import org.elasticsearch.common.xcontent.XContentHelper;
22+
import org.elasticsearch.core.Nullable;
2223
import org.elasticsearch.rest.RestStatus;
2324
import org.elasticsearch.test.ESTestCase;
2425
import org.elasticsearch.xcontent.XContentType;
@@ -29,24 +30,27 @@
2930
import java.net.InetSocketAddress;
3031
import java.net.URI;
3132
import java.util.HashMap;
33+
import java.util.List;
3234
import java.util.Map;
3335
import java.util.Set;
3436

3537
import static org.hamcrest.Matchers.aMapWithSize;
3638

3739
public class Ec2ImdsHttpHandlerTests extends ESTestCase {
3840

41+
private static final String SECURITY_CREDENTIALS_URI = "/latest/meta-data/iam/security-credentials/";
42+
3943
public void testImdsV1() throws IOException {
4044
final Map<String, String> generatedCredentials = new HashMap<>();
4145

42-
final var handler = new Ec2ImdsHttpHandler(generatedCredentials::put, Set.of());
46+
final var handler = new Ec2ImdsHttpHandler(Ec2ImdsVersion.V1, generatedCredentials::put, Set.of());
4347

44-
final var roleResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/");
48+
final var roleResponse = handleRequest(handler, "GET", SECURITY_CREDENTIALS_URI);
4549
assertEquals(RestStatus.OK, roleResponse.status());
4650
final var profileName = roleResponse.body().utf8ToString();
4751
assertTrue(Strings.hasText(profileName));
4852

49-
final var credentialsResponse = handleRequest(handler, "GET", "/latest/meta-data/iam/security-credentials/" + profileName);
53+
final var credentialsResponse = handleRequest(handler, "GET", SECURITY_CREDENTIALS_URI + profileName);
5054
assertEquals(RestStatus.OK, credentialsResponse.status());
5155

5256
assertThat(generatedCredentials, aMapWithSize(1));
@@ -62,14 +66,67 @@ public void testImdsV1() throws IOException {
6266
public void testImdsV2Disabled() {
6367
assertEquals(
6468
RestStatus.METHOD_NOT_ALLOWED,
65-
handleRequest(new Ec2ImdsHttpHandler((accessKey, sessionToken) -> fail(), Set.of()), "PUT", "/latest/api/token").status()
69+
handleRequest(
70+
new Ec2ImdsHttpHandler(Ec2ImdsVersion.V1, (accessKey, sessionToken) -> fail(), Set.of()),
71+
"PUT",
72+
"/latest/api/token"
73+
).status()
6674
);
6775
}
6876

77+
public void testImdsV2() throws IOException {
78+
final Map<String, String> generatedCredentials = new HashMap<>();
79+
80+
final var handler = new Ec2ImdsHttpHandler(Ec2ImdsVersion.V2, generatedCredentials::put, Set.of());
81+
82+
final var tokenResponse = handleRequest(handler, "PUT", "/latest/api/token");
83+
assertEquals(RestStatus.OK, tokenResponse.status());
84+
final var token = tokenResponse.body().utf8ToString();
85+
86+
final var roleResponse = checkImdsV2GetRequest(handler, SECURITY_CREDENTIALS_URI, token);
87+
assertEquals(RestStatus.OK, roleResponse.status());
88+
final var profileName = roleResponse.body().utf8ToString();
89+
assertTrue(Strings.hasText(profileName));
90+
91+
final var credentialsResponse = checkImdsV2GetRequest(handler, SECURITY_CREDENTIALS_URI + profileName, token);
92+
assertEquals(RestStatus.OK, credentialsResponse.status());
93+
94+
assertThat(generatedCredentials, aMapWithSize(1));
95+
final var accessKey = generatedCredentials.keySet().iterator().next();
96+
final var sessionToken = generatedCredentials.values().iterator().next();
97+
98+
final var responseMap = XContentHelper.convertToMap(XContentType.JSON.xContent(), credentialsResponse.body().streamInput(), false);
99+
assertEquals(Set.of("AccessKeyId", "Expiration", "RoleArn", "SecretAccessKey", "Token"), responseMap.keySet());
100+
assertEquals(accessKey, responseMap.get("AccessKeyId"));
101+
assertEquals(sessionToken, responseMap.get("Token"));
102+
}
103+
69104
private record TestHttpResponse(RestStatus status, BytesReference body) {}
70105

106+
private static TestHttpResponse checkImdsV2GetRequest(Ec2ImdsHttpHandler handler, String uri, String token) {
107+
final var unauthorizedResponse = handleRequest(handler, "GET", uri, null);
108+
assertEquals(RestStatus.UNAUTHORIZED, unauthorizedResponse.status());
109+
110+
final var forbiddenResponse = handleRequest(handler, "GET", uri, randomValueOtherThan(token, ESTestCase::randomSecretKey));
111+
assertEquals(RestStatus.FORBIDDEN, forbiddenResponse.status());
112+
113+
return handleRequest(handler, "GET", uri, token);
114+
}
115+
71116
private static TestHttpResponse handleRequest(Ec2ImdsHttpHandler handler, String method, String uri) {
72-
final var httpExchange = new TestHttpExchange(method, uri, BytesArray.EMPTY, TestHttpExchange.EMPTY_HEADERS);
117+
return handleRequest(handler, method, uri, null);
118+
}
119+
120+
private static TestHttpResponse handleRequest(Ec2ImdsHttpHandler handler, String method, String uri, @Nullable String token) {
121+
final Headers headers;
122+
if (token == null) {
123+
headers = TestHttpExchange.EMPTY_HEADERS;
124+
} else {
125+
headers = new Headers();
126+
headers.put("X-aws-ec2-metadata-token", List.of(token));
127+
}
128+
129+
final var httpExchange = new TestHttpExchange(method, uri, BytesArray.EMPTY, headers);
73130
try {
74131
handler.handle(httpExchange);
75132
} catch (IOException e) {

0 commit comments

Comments
 (0)