Skip to content

Commit

Permalink
feat(credential-provider-imds): support IMDS for IPv6 endpoints (#2660)
Browse files Browse the repository at this point in the history
  • Loading branch information
trivikr committed Aug 13, 2021
1 parent 885cbc4 commit c458481
Show file tree
Hide file tree
Showing 13 changed files with 278 additions and 24 deletions.
2 changes: 2 additions & 0 deletions packages/credential-provider-imds/package.json
Expand Up @@ -21,8 +21,10 @@
},
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/node-config-provider": "3.25.0",
"@aws-sdk/property-provider": "3.25.0",
"@aws-sdk/types": "3.25.0",
"@aws-sdk/url-parser": "3.25.0",
"tslib": "^2.3.0"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions packages/credential-provider-imds/src/config/Endpoint.ts
@@ -0,0 +1,4 @@
export enum Endpoint {
IPv4 = "http://169.254.169.254",
IPv6 = "http://[fd00:ec2::254]",
}
@@ -0,0 +1,22 @@
import { CONFIG_ENDPOINT_NAME, ENDPOINT_CONFIG_OPTIONS, ENV_ENDPOINT_NAME } from "./EndpointConfigOptions";

describe("ENDPOINT_CONFIG_OPTIONS", () => {
describe("environmentVariableSelector", () => {
const { environmentVariableSelector } = ENDPOINT_CONFIG_OPTIONS;
it.each([undefined, "mockEndpoint"])(`when env[${ENV_ENDPOINT_NAME}]: %s`, (mockEndpoint) => {
expect(environmentVariableSelector({ [ENV_ENDPOINT_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

describe("configFileSelector", () => {
const { configFileSelector } = ENDPOINT_CONFIG_OPTIONS;
it.each([undefined, "mockEndpoint"])(`when env[${CONFIG_ENDPOINT_NAME}]: %s`, (mockEndpoint) => {
expect(configFileSelector({ [CONFIG_ENDPOINT_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

it("default returns undefined", () => {
const { default: defaultKey } = ENDPOINT_CONFIG_OPTIONS;
expect(defaultKey).toBe(undefined);
});
});
@@ -0,0 +1,10 @@
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";

export const ENV_ENDPOINT_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT";
export const CONFIG_ENDPOINT_NAME = "ec2_metadata_service_endpoint";

export const ENDPOINT_CONFIG_OPTIONS: LoadedConfigSelectors<string | undefined> = {
environmentVariableSelector: (env) => env[ENV_ENDPOINT_NAME],
configFileSelector: (profile) => profile[CONFIG_ENDPOINT_NAME],
default: undefined,
};
4 changes: 4 additions & 0 deletions packages/credential-provider-imds/src/config/EndpointMode.ts
@@ -0,0 +1,4 @@
export enum EndpointMode {
IPv4 = "IPv4",
IPv6 = "IPv6",
}
@@ -0,0 +1,27 @@
import { EndpointMode } from "./EndpointMode";
import {
CONFIG_ENDPOINT_MODE_NAME,
ENDPOINT_MODE_CONFIG_OPTIONS,
ENV_ENDPOINT_MODE_NAME,
} from "./EndpointModeConfigOptions";

describe("ENDPOINT_MODE_CONFIG_OPTIONS", () => {
describe("environmentVariableSelector", () => {
const { environmentVariableSelector } = ENDPOINT_MODE_CONFIG_OPTIONS;
it.each([undefined, "mockEndpointMode"])(`when env[${ENV_ENDPOINT_MODE_NAME}]: %s`, (mockEndpoint) => {
expect(environmentVariableSelector({ [ENV_ENDPOINT_MODE_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

describe("configFileSelector", () => {
const { configFileSelector } = ENDPOINT_MODE_CONFIG_OPTIONS;
it.each([undefined, "mockEndpointMode"])(`when env[${CONFIG_ENDPOINT_MODE_NAME}]: %s`, (mockEndpoint) => {
expect(configFileSelector({ [CONFIG_ENDPOINT_MODE_NAME]: mockEndpoint })).toBe(mockEndpoint);
});
});

it(`default returns ${EndpointMode.IPv4}`, () => {
const { default: defaultKey } = ENDPOINT_MODE_CONFIG_OPTIONS;
expect(defaultKey).toBe(EndpointMode.IPv4);
});
});
@@ -0,0 +1,12 @@
import { LoadedConfigSelectors } from "@aws-sdk/node-config-provider";

import { EndpointMode } from "./EndpointMode";

export const ENV_ENDPOINT_MODE_NAME = "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE";
export const CONFIG_ENDPOINT_MODE_NAME = "ec2_metadata_service_endpoint_mode";

export const ENDPOINT_MODE_CONFIG_OPTIONS: LoadedConfigSelectors<string | undefined> = {
environmentVariableSelector: (env) => env[ENV_ENDPOINT_MODE_NAME],
configFileSelector: (profile) => profile[CONFIG_ENDPOINT_MODE_NAME],
default: EndpointMode.IPv4,
};
Expand Up @@ -5,21 +5,23 @@ import { httpRequest } from "./remoteProvider/httpRequest";
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
import { providerConfigFromInit } from "./remoteProvider/RemoteProviderInit";
import { retry } from "./remoteProvider/retry";
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";

jest.mock("./remoteProvider/httpRequest");
jest.mock("./remoteProvider/ImdsCredentials");
jest.mock("./remoteProvider/retry");
jest.mock("./remoteProvider/RemoteProviderInit");
jest.mock("./utils/getInstanceMetadataEndpoint");

describe("fromInstanceMetadata", () => {
const host = "169.254.169.254";
const hostname = "127.0.0.1";
const mockTimeout = 1000;
const mockMaxRetries = 3;
const mockToken = "fooToken";
const mockProfile = "fooProfile";

const mockTokenRequestOptions = {
host,
hostname,
path: "/latest/api/token",
method: "PUT",
headers: {
Expand All @@ -29,7 +31,7 @@ describe("fromInstanceMetadata", () => {
};

const mockProfileRequestOptions = {
host,
hostname,
path: "/latest/meta-data/iam/security-credentials/",
timeout: mockTimeout,
headers: {
Expand All @@ -52,6 +54,7 @@ describe("fromInstanceMetadata", () => {
});

beforeEach(() => {
(getInstanceMetadataEndpoint as jest.Mock).mockResolvedValue({ hostname });
(isImdsCredentials as unknown as jest.Mock).mockReturnValue(true);
(providerConfigFromInit as jest.Mock).mockReturnValue({
timeout: mockTimeout,
Expand Down
17 changes: 8 additions & 9 deletions packages/credential-provider-imds/src/fromInstanceMetadata.ts
Expand Up @@ -6,8 +6,8 @@ import { httpRequest } from "./remoteProvider/httpRequest";
import { fromImdsCredentials, isImdsCredentials } from "./remoteProvider/ImdsCredentials";
import { providerConfigFromInit, RemoteProviderInit } from "./remoteProvider/RemoteProviderInit";
import { retry } from "./remoteProvider/retry";
import { getInstanceMetadataEndpoint } from "./utils/getInstanceMetadataEndpoint";

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

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

return async () => {
const endpoint = await getInstanceMetadataEndpoint();
if (disableFetchToken) {
return getCredentials(maxRetries, { timeout });
return getCredentials(maxRetries, { ...endpoint, timeout });
} else {
let token: string;
try {
token = (await getMetadataToken({ timeout })).toString();
token = (await getMetadataToken({ ...endpoint, timeout })).toString();
} catch (error) {
if (error?.statusCode === 400) {
throw Object.assign(error, {
Expand All @@ -65,13 +66,14 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
} else if (error.message === "TimeoutError" || [403, 404, 405].includes(error.statusCode)) {
disableFetchToken = true;
}
return getCredentials(maxRetries, { timeout });
return getCredentials(maxRetries, { ...endpoint, timeout });
}
return getCredentials(maxRetries, {
timeout,
...endpoint,
headers: {
"x-aws-ec2-metadata-token": token,
},
timeout,
});
}
};
Expand All @@ -80,23 +82,20 @@ export const fromInstanceMetadata = (init: RemoteProviderInit = {}): CredentialP
const getMetadataToken = async (options: RequestOptions) =>
httpRequest({
...options,
host: IMDS_IP,
path: IMDS_TOKEN_PATH,
method: "PUT",
headers: {
"x-aws-ec2-metadata-token-ttl-seconds": "21600",
},
});

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

const getCredentialsFromProfile = async (profile: string, options: RequestOptions) => {
const credsResponse = JSON.parse(
(
await httpRequest({
...options,
host: IMDS_IP,
path: IMDS_PATH + profile,
})
).toString()
Expand Down
Expand Up @@ -7,7 +7,7 @@ import { httpRequest } from "./httpRequest";
describe("httpRequest", () => {
const requestSpy = jest.spyOn(http, "request");
let port: number;
const host = "localhost";
const hostname = "localhost";
const path = "/";

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

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

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

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

scope.done();
});

it("works with IPv6 hostname with encapsulated brackets", async () => {
const expectedResponse = "expectedResponse";
const encapsulatedIPv6Hostname = "[::1]";
const scope = nock(`http://${encapsulatedIPv6Hostname}:${port}`).get(path).reply(200, expectedResponse);

const response = await httpRequest({ hostname: encapsulatedIPv6Hostname, path, port });
expect(response.toString()).toStrictEqual(expectedResponse);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);

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

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

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

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

it("timeout", async () => {
const timeout = 1000;
const scope = nock(`http://${host}:${port}`)
const scope = nock(`http://${hostname}:${port}`)
.get(path)
.delay(timeout * 2)
.reply(200, "expectedResponse");

await expect(httpRequest({ host, path, port, timeout })).rejects.toStrictEqual(
await expect(httpRequest({ hostname, path, port, timeout })).rejects.toStrictEqual(
new ProviderError("TimeoutError from instance metadata service")
);
expect(requestSpy.mock.results[0].value.socket).toHaveProperty("destroyed", true);
Expand Down
Expand Up @@ -7,7 +7,13 @@ import { IncomingMessage, request, RequestOptions } from "http";
*/
export function httpRequest(options: RequestOptions): Promise<Buffer> {
return new Promise((resolve, reject) => {
const req = request({ method: "GET", ...options });
const req = request({
method: "GET",
...options,
// Node.js http module doesn't accept hostname with square brackets
// Refs: https://github.com/nodejs/node/issues/39738
hostname: options.hostname?.replace(/^\[(.+)\]$/, "$1"),
});

req.on("error", (err) => {
reject(Object.assign(new ProviderError("Unable to connect to instance metadata service"), err));
Expand Down

0 comments on commit c458481

Please sign in to comment.