Skip to content

Commit c458481

Browse files
authored
feat(credential-provider-imds): support IMDS for IPv6 endpoints (#2660)
1 parent 885cbc4 commit c458481

13 files changed

+278
-24
lines changed

packages/credential-provider-imds/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@
2121
},
2222
"license": "Apache-2.0",
2323
"dependencies": {
24+
"@aws-sdk/node-config-provider": "3.25.0",
2425
"@aws-sdk/property-provider": "3.25.0",
2526
"@aws-sdk/types": "3.25.0",
27+
"@aws-sdk/url-parser": "3.25.0",
2628
"tslib": "^2.3.0"
2729
},
2830
"devDependencies": {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum Endpoint {
2+
IPv4 = "http://169.254.169.254",
3+
IPv6 = "http://[fd00:ec2::254]",
4+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { CONFIG_ENDPOINT_NAME, ENDPOINT_CONFIG_OPTIONS, ENV_ENDPOINT_NAME } from "./EndpointConfigOptions";
2+
3+
describe("ENDPOINT_CONFIG_OPTIONS", () => {
4+
describe("environmentVariableSelector", () => {
5+
const { environmentVariableSelector } = ENDPOINT_CONFIG_OPTIONS;
6+
it.each([undefined, "mockEndpoint"])(`when env[${ENV_ENDPOINT_NAME}]: %s`, (mockEndpoint) => {
7+
expect(environmentVariableSelector({ [ENV_ENDPOINT_NAME]: mockEndpoint })).toBe(mockEndpoint);
8+
});
9+
});
10+
11+
describe("configFileSelector", () => {
12+
const { configFileSelector } = ENDPOINT_CONFIG_OPTIONS;
13+
it.each([undefined, "mockEndpoint"])(`when env[${CONFIG_ENDPOINT_NAME}]: %s`, (mockEndpoint) => {
14+
expect(configFileSelector({ [CONFIG_ENDPOINT_NAME]: mockEndpoint })).toBe(mockEndpoint);
15+
});
16+
});
17+
18+
it("default returns undefined", () => {
19+
const { default: defaultKey } = ENDPOINT_CONFIG_OPTIONS;
20+
expect(defaultKey).toBe(undefined);
21+
});
22+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";
2+
3+
export const ENV_ENDPOINT_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT";
4+
export const CONFIG_ENDPOINT_NAME = "ec2_metadata_service_endpoint";
5+
6+
export const ENDPOINT_CONFIG_OPTIONS: LoadedConfigSelectors<string | undefined> = {
7+
environmentVariableSelector: (env) => env[ENV_ENDPOINT_NAME],
8+
configFileSelector: (profile) => profile[CONFIG_ENDPOINT_NAME],
9+
default: undefined,
10+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum EndpointMode {
2+
IPv4 = "IPv4",
3+
IPv6 = "IPv6",
4+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { EndpointMode } from "./EndpointMode";
2+
import {
3+
CONFIG_ENDPOINT_MODE_NAME,
4+
ENDPOINT_MODE_CONFIG_OPTIONS,
5+
ENV_ENDPOINT_MODE_NAME,
6+
} from "./EndpointModeConfigOptions";
7+
8+
describe("ENDPOINT_MODE_CONFIG_OPTIONS", () => {
9+
describe("environmentVariableSelector", () => {
10+
const { environmentVariableSelector } = ENDPOINT_MODE_CONFIG_OPTIONS;
11+
it.each([undefined, "mockEndpointMode"])(`when env[${ENV_ENDPOINT_MODE_NAME}]: %s`, (mockEndpoint) => {
12+
expect(environmentVariableSelector({ [ENV_ENDPOINT_MODE_NAME]: mockEndpoint })).toBe(mockEndpoint);
13+
});
14+
});
15+
16+
describe("configFileSelector", () => {
17+
const { configFileSelector } = ENDPOINT_MODE_CONFIG_OPTIONS;
18+
it.each([undefined, "mockEndpointMode"])(`when env[${CONFIG_ENDPOINT_MODE_NAME}]: %s`, (mockEndpoint) => {
19+
expect(configFileSelector({ [CONFIG_ENDPOINT_MODE_NAME]: mockEndpoint })).toBe(mockEndpoint);
20+
});
21+
});
22+
23+
it(`default returns ${EndpointMode.IPv4}`, () => {
24+
const { default: defaultKey } = ENDPOINT_MODE_CONFIG_OPTIONS;
25+
expect(defaultKey).toBe(EndpointMode.IPv4);
26+
});
27+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";
2+
3+
import { EndpointMode } from "./EndpointMode";
4+
5+
export const ENV_ENDPOINT_MODE_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE";
6+
export const CONFIG_ENDPOINT_MODE_NAME = "ec2_metadata_service_endpoint_mode";
7+
8+
export const ENDPOINT_MODE_CONFIG_OPTIONS: LoadedConfigSelectors<string | undefined> = {
9+
environmentVariableSelector: (env) => env[ENV_ENDPOINT_MODE_NAME],
10+
configFileSelector: (profile) => profile[CONFIG_ENDPOINT_MODE_NAME],
11+
default: EndpointMode.IPv4,
12+
};

packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,23 @@ import { httpRequest } from "./remoteProvider/httpRequest";
55
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
66
import { providerConfigFromInit } from "./remoteProvider/RemoteProviderInit";
77
import { retry } from "./remoteProvider/retry";
8+
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";
89

910
jest.mock("./remoteProvider/httpRequest");
1011
jest.mock("./remoteProvider/ImdsCredentials");
1112
jest.mock("./remoteProvider/retry");
1213
jest.mock("./remoteProvider/RemoteProviderInit");
14+
jest.mock("./utils/getInstanceMetadataEndpoint");
1315

1416
describe("fromInstanceMetadata", () => {
15-
const host = "169.254.169.254";
17+
const hostname = "127.0.0.1";
1618
const mockTimeout = 1000;
1719
const mockMaxRetries = 3;
1820
const mockToken = "fooToken";
1921
const mockProfile = "fooProfile";
2022

2123
const mockTokenRequestOptions = {
22-
host,
24+
hostname,
2325
path: "/latest/api/token",
2426
method: "PUT",
2527
headers: {
@@ -29,7 +31,7 @@ describe("fromInstanceMetadata", () => {
2931
};
3032

3133
const mockProfileRequestOptions = {
32-
host,
34+
hostname,
3335
path: "/latest/meta-data/iam/security-credentials/",
3436
timeout: mockTimeout,
3537
headers: {
@@ -52,6 +54,7 @@ describe("fromInstanceMetadata", () => {
5254
});
5355

5456
beforeEach(() => {
57+
(getInstanceMetadataEndpoint as jest.Mock).mockResolvedValue({ hostname });
5558
(isImdsCredentials as unknown as jest.Mock).mockReturnValue(true);
5659
(providerConfigFromInit as jest.Mock).mockReturnValue({
5760
timeout: mockTimeout,

packages/credential-provider-imds/src/fromInstanceMetadata.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { httpRequest } from "./remoteProvider/httpRequest";
66
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
77
import { providerConfigFromInit, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit";
88
import { retry } from "./remoteProvider/retry";
9+
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";
910

10-
const IMDS_IP = "169.254.169.254";
1111
const IMDS_PATH = "/latest/meta-data/iam/security-credentials/";
1212
const IMDS_TOKEN_PATH = "/latest/api/token";
1313

@@ -51,12 +51,13 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
5151
};
5252

5353
return async () => {
54+
const endpoint = await getInstanceMetadataEndpoint();
5455
if (disableFetchToken) {
55-
return getCredentials(maxRetries, { timeout });
56+
return getCredentials(maxRetries, { ...endpoint, timeout });
5657
} else {
5758
let token: string;
5859
try {
59-
token = (await getMetadataToken({ timeout })).toString();
60+
token = (await getMetadataToken({ ...endpoint, timeout })).toString();
6061
} catch (error) {
6162
if (error?.statusCode === 400) {
6263
throw Object.assign(error, {
@@ -65,13 +66,14 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
6566
} else if (error.message === "TimeoutError" || [403, 404, 405].includes(error.statusCode)) {
6667
disableFetchToken = true;
6768
}
68-
return getCredentials(maxRetries, { timeout });
69+
return getCredentials(maxRetries, { ...endpoint, timeout });
6970
}
7071
return getCredentials(maxRetries, {
71-
timeout,
72+
...endpoint,
7273
headers: {
7374
"x-aws-ec2-metadata-token": token,
7475
},
76+
timeout,
7577
});
7678
}
7779
};
@@ -80,23 +82,20 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
8082
const getMetadataToken = async (options: RequestOptions) =>
8183
httpRequest({
8284
...options,
83-
host: IMDS_IP,
8485
path: IMDS_TOKEN_PATH,
8586
method: "PUT",
8687
headers: {
8788
"x-aws-ec2-metadata-token-ttl-seconds": "21600",
8889
},
8990
});
9091

91-
const getProfile = async (options: RequestOptions) =>
92-
(await httpRequest({ ...options, host: IMDS_IP, path: IMDS_PATH })).toString();
92+
const getProfile = async (options: RequestOptions) => (await httpRequest({ ...options, path: IMDS_PATH })).toString();
9393

9494
const getCredentialsFromProfile = async (profile: string, options: RequestOptions) => {
9595
const credsResponse = JSON.parse(
9696
(
9797
await httpRequest({
9898
...options,
99-
host: IMDS_IP,
10099
path: IMDS_PATH + profile,
101100
})
102101
).toString()

packages/credential-provider-imds/src/remoteProvider/httpRequest.spec.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { httpRequest } from "./httpRequest";
77
describe("httpRequest", () => {
88
const requestSpy = jest.spyOn(http, "request");
99
let port: number;
10-
const host = "localhost";
10+
const hostname = "localhost";
1111
const path = "/";
1212

1313
const getOpenPort = async (candidatePort = 4321): Promise<number> => {
@@ -34,9 +34,9 @@ describe("httpRequest", () => {
3434
describe("returns response", () => {
3535
it("defaults to method GET", async () => {
3636
const expectedResponse = "expectedResponse";
37-
const scope = nock(`http://${host}:${port}`).get(path).reply(200, expectedResponse);
37+
const scope = nock(`http://${hostname}:${port}`).get(path).reply(200, expectedResponse);
3838

39-
const response = await httpRequest({ host, path, port });
39+
const response = await httpRequest({ hostname, path, port });
4040
expect(response.toString()).toStrictEqual(expectedResponse);
4141
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
4242

@@ -46,9 +46,21 @@ describe("httpRequest", () => {
4646
it("uses method passed in options", async () => {
4747
const method = "POST";
4848
const expectedResponse = "expectedResponse";
49-
const scope = nock(`http://${host}:${port}`).post(path).reply(200, expectedResponse);
49+
const scope = nock(`http://${hostname}:${port}`).post(path).reply(200, expectedResponse);
5050

51-
const response = await httpRequest({ host, path, port, method });
51+
const response = await httpRequest({ hostname, path, port, method });
52+
expect(response.toString()).toStrictEqual(expectedResponse);
53+
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
54+
55+
scope.done();
56+
});
57+
58+
it("works with IPv6 hostname with encapsulated brackets", async () => {
59+
const expectedResponse = "expectedResponse";
60+
const encapsulatedIPv6Hostname = "[::1]";
61+
const scope = nock(`http://${encapsulatedIPv6Hostname}:${port}`).get(path).reply(200, expectedResponse);
62+
63+
const response = await httpRequest({ hostname: encapsulatedIPv6Hostname, path, port });
5264
expect(response.toString()).toStrictEqual(expectedResponse);
5365
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
5466

@@ -59,9 +71,9 @@ describe("httpRequest", () => {
5971
describe("throws error", () => {
6072
const errorOnStatusCode = async (statusCode: number) => {
6173
it(`statusCode: ${statusCode}`, async () => {
62-
const scope = nock(`http://${host}:${port}`).get(path).reply(statusCode, "continue");
74+
const scope = nock(`http://${hostname}:${port}`).get(path).reply(statusCode, "continue");
6375

64-
await expect(httpRequest({ host, path, port })).rejects.toStrictEqual(
76+
await expect(httpRequest({ hostname, path, port })).rejects.toStrictEqual(
6577
Object.assign(new ProviderError("Error response received from instance metadata service"), { statusCode })
6678
);
6779
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
@@ -71,9 +83,9 @@ describe("httpRequest", () => {
7183
};
7284

7385
it("when request throws error", async () => {
74-
const scope = nock(`http://${host}:${port}`).get(path).replyWithError("error");
86+
const scope = nock(`http://${hostname}:${port}`).get(path).replyWithError("error");
7587

76-
await expect(httpRequest({ host, path, port })).rejects.toStrictEqual(
88+
await expect(httpRequest({ hostname, path, port })).rejects.toStrictEqual(
7789
new ProviderError("Unable to connect to instance metadata service")
7890
);
7991
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
@@ -92,12 +104,12 @@ describe("httpRequest", () => {
92104

93105
it("timeout", async () => {
94106
const timeout = 1000;
95-
const scope = nock(`http://${host}:${port}`)
107+
const scope = nock(`http://${hostname}:${port}`)
96108
.get(path)
97109
.delay(timeout * 2)
98110
.reply(200, "expectedResponse");
99111

100-
await expect(httpRequest({ host, path, port, timeout })).rejects.toStrictEqual(
112+
await expect(httpRequest({ hostname, path, port, timeout })).rejects.toStrictEqual(
101113
new ProviderError("TimeoutError from instance metadata service")
102114
);
103115
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);

0 commit comments

Comments
 (0)