diff --git a/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts b/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts index 1871a28c9402..2196ba610b2f 100644 --- a/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts +++ b/packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts @@ -14,16 +14,31 @@ jest.mock("./remoteProvider/retry"); jest.mock("./remoteProvider/RemoteProviderInit"); describe("fromInstanceMetadata", () => { + const host = "169.254.169.254"; const mockTimeout = 1000; const mockMaxRetries = 3; - const mockProfile = "foo"; + const mockToken = "fooToken"; + const mockProfile = "fooProfile"; - const mockHttpRequestOptions = { - host: "169.254.169.254", - path: "/latest/meta-data/iam/security-credentials/", + const mockTokenRequestOptions = { + host, + path: "/latest/api/token", + method: "PUT", + headers: { + "x-aws-ec2-metadata-token-ttl-seconds": "21600" + }, timeout: mockTimeout }; + const mockProfileRequestOptions = { + host, + path: "/latest/meta-data/iam/security-credentials/", + timeout: mockTimeout, + headers: { + "x-aws-ec2-metadata-token": mockToken + } + }; + const mockImdsCreds = Object.freeze({ AccessKeyId: "foo", SecretAccessKey: "bar", @@ -50,8 +65,9 @@ describe("fromInstanceMetadata", () => { jest.resetAllMocks(); }); - it("gets profile name from IMDS, and passes profile name to fetch credentials", async () => { + it("gets token and profile name to fetch credentials", async () => { (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockToken) .mockResolvedValueOnce(mockProfile) .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); @@ -59,16 +75,18 @@ describe("fromInstanceMetadata", () => { (fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds); await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds); - expect(httpRequest).toHaveBeenCalledTimes(2); - expect(httpRequest).toHaveBeenNthCalledWith(1, mockHttpRequestOptions); - expect(httpRequest).toHaveBeenNthCalledWith(2, { - ...mockHttpRequestOptions, - path: `${mockHttpRequestOptions.path}${mockProfile}` + expect(httpRequest).toHaveBeenCalledTimes(3); + expect(httpRequest).toHaveBeenNthCalledWith(1, mockTokenRequestOptions); + expect(httpRequest).toHaveBeenNthCalledWith(2, mockProfileRequestOptions); + expect(httpRequest).toHaveBeenNthCalledWith(3, { + ...mockProfileRequestOptions, + path: `${mockProfileRequestOptions.path}${mockProfile}` }); }); it("trims profile returned name from IMDS", async () => { (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockToken) .mockResolvedValueOnce(" " + mockProfile + " ") .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); @@ -76,11 +94,9 @@ describe("fromInstanceMetadata", () => { (fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds); await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds); - expect(httpRequest).toHaveBeenCalledTimes(2); - expect(httpRequest).toHaveBeenNthCalledWith(1, mockHttpRequestOptions); - expect(httpRequest).toHaveBeenNthCalledWith(2, { - ...mockHttpRequestOptions, - path: `${mockHttpRequestOptions.path}${mockProfile}` + expect(httpRequest).toHaveBeenNthCalledWith(3, { + ...mockProfileRequestOptions, + path: `${mockProfileRequestOptions.path}${mockProfile}` }); }); @@ -118,6 +134,7 @@ describe("fromInstanceMetadata", () => { it("throws ProviderError if credentials returned are incorrect", async () => { (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockToken) .mockResolvedValueOnce(mockProfile) .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); @@ -130,37 +147,41 @@ describe("fromInstanceMetadata", () => { ) ); expect(retry).toHaveBeenCalledTimes(2); - expect(httpRequest).toHaveBeenCalledTimes(2); + expect(httpRequest).toHaveBeenCalledTimes(3); expect(isImdsCredentials).toHaveBeenCalledTimes(1); expect(isImdsCredentials).toHaveBeenCalledWith(mockImdsCreds); expect(fromImdsCredentials).not.toHaveBeenCalled(); }); - it("throws Error if requestFromEc2Imds for profile fails", async () => { + it("throws Error if httpRequest for profile fails", async () => { const mockError = new Error("profile not found"); - (httpRequest as jest.Mock).mockRejectedValueOnce(mockError); + (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockToken) + .mockRejectedValueOnce(mockError); (retry as jest.Mock).mockImplementation((fn: any) => fn()); await expect(fromInstanceMetadata()()).rejects.toEqual(mockError); expect(retry).toHaveBeenCalledTimes(1); - expect(httpRequest).toHaveBeenCalledTimes(1); + expect(httpRequest).toHaveBeenCalledTimes(2); }); - it("throws Error if requestFromEc2Imds for credentials fails", async () => { + it("throws Error if httpRequest for credentials fails", async () => { const mockError = new Error("creds not found"); (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockToken) .mockResolvedValueOnce(mockProfile) .mockRejectedValueOnce(mockError); (retry as jest.Mock).mockImplementation((fn: any) => fn()); await expect(fromInstanceMetadata()()).rejects.toEqual(mockError); expect(retry).toHaveBeenCalledTimes(2); - expect(httpRequest).toHaveBeenCalledTimes(2); + expect(httpRequest).toHaveBeenCalledTimes(3); expect(fromImdsCredentials).not.toHaveBeenCalled(); }); - it("throws SyntaxError if requestFromEc2Imds returns unparseable creds", async () => { + it("throws SyntaxError if httpRequest returns unparseable creds", async () => { (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockToken) .mockResolvedValueOnce(mockProfile) .mockResolvedValueOnce("."); (retry as jest.Mock).mockImplementation((fn: any) => fn()); @@ -169,7 +190,136 @@ describe("fromInstanceMetadata", () => { new SyntaxError("Unexpected token . in JSON at position 0") ); expect(retry).toHaveBeenCalledTimes(2); - expect(httpRequest).toHaveBeenCalledTimes(2); + expect(httpRequest).toHaveBeenCalledTimes(3); expect(fromImdsCredentials).not.toHaveBeenCalled(); }); + + it("throws error if metadata token errors with statusCode 400", async () => { + const tokenError = Object.assign(new Error("token not found"), { + statusCode: 400 + }); + (httpRequest as jest.Mock).mockRejectedValueOnce(tokenError); + + await expect(fromInstanceMetadata()()).rejects.toEqual(tokenError); + }); + + describe("disables fetching of token", () => { + beforeEach(() => { + (retry as jest.Mock).mockImplementation((fn: any) => fn()); + (fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds); + }); + + it("when token fetch returns with TimeoutError", async () => { + const tokenError = new Error("TimeoutError"); + + (httpRequest as jest.Mock) + .mockRejectedValueOnce(tokenError) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + const fromInstanceMetadataFunc = fromInstanceMetadata(); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + }); + + [403, 404, 405].forEach(statusCode => { + it(`when token fetch errors with statusCode ${statusCode}`, async () => { + const tokenError = Object.assign(new Error(), { statusCode }); + + (httpRequest as jest.Mock) + .mockRejectedValueOnce(tokenError) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + const fromInstanceMetadataFunc = fromInstanceMetadata(); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + }); + }); + }); + + it("uses insecure data flow once, if error is not TimeoutError", async () => { + const tokenError = new Error("Error"); + + (httpRequest as jest.Mock) + .mockRejectedValueOnce(tokenError) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)) + .mockResolvedValueOnce(mockToken) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + (retry as jest.Mock).mockImplementation((fn: any) => fn()); + (fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds); + + const fromInstanceMetadataFunc = fromInstanceMetadata(); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + }); + + it("uses insecure data flow once, if error statusCode is not 400, 403, 404, 405", async () => { + const tokenError = Object.assign(new Error("Error"), { statusCode: 406 }); + + (httpRequest as jest.Mock) + .mockRejectedValueOnce(tokenError) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)) + .mockResolvedValueOnce(mockToken) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + (retry as jest.Mock).mockImplementation((fn: any) => fn()); + (fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds); + + const fromInstanceMetadataFunc = fromInstanceMetadata(); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + }); + + describe("re-enables fetching of token", () => { + const error401 = Object.assign(new Error("error"), { statusCode: 401 }); + + beforeEach(() => { + const tokenError = new Error("TimeoutError"); + + (httpRequest as jest.Mock) + .mockRejectedValueOnce(tokenError) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + (retry as jest.Mock).mockImplementation((fn: any) => fn()); + (fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds); + }); + + it("when profile error with 401", async () => { + (httpRequest as jest.Mock) + .mockRejectedValueOnce(error401) + .mockResolvedValueOnce(mockToken) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + const fromInstanceMetadataFunc = fromInstanceMetadata(); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + await expect(fromInstanceMetadataFunc()).rejects.toEqual(error401); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + }); + + it("when creds error with 401", async () => { + (httpRequest as jest.Mock) + .mockResolvedValueOnce(mockProfile) + .mockRejectedValueOnce(error401) + .mockResolvedValueOnce(mockToken) + .mockResolvedValueOnce(mockProfile) + .mockResolvedValueOnce(JSON.stringify(mockImdsCreds)); + + const fromInstanceMetadataFunc = fromInstanceMetadata(); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + await expect(fromInstanceMetadataFunc()).rejects.toEqual(error401); + await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds); + }); + }); }); diff --git a/packages/credential-provider-imds/src/fromInstanceMetadata.ts b/packages/credential-provider-imds/src/fromInstanceMetadata.ts index 45eabf71f924..b0b298123bc6 100644 --- a/packages/credential-provider-imds/src/fromInstanceMetadata.ts +++ b/packages/credential-provider-imds/src/fromInstanceMetadata.ts @@ -1,4 +1,4 @@ -import { CredentialProvider } from "@aws-sdk/types"; +import { CredentialProvider, Credentials } from "@aws-sdk/types"; import { RemoteProviderInit, providerConfigFromInit @@ -10,9 +10,11 @@ import { } from "./remoteProvider/ImdsCredentials"; import { retry } from "./remoteProvider/retry"; import { ProviderError } from "@aws-sdk/property-provider"; +import { RequestOptions } from "http"; const IMDS_IP = "169.254.169.254"; const IMDS_PATH = "/latest/meta-data/iam/security-credentials/"; +const IMDS_TOKEN_PATH = "/latest/api/token"; /** * Creates a credential provider that will source credentials from the EC2 @@ -21,35 +23,108 @@ const IMDS_PATH = "/latest/meta-data/iam/security-credentials/"; export const fromInstanceMetadata = ( init: RemoteProviderInit = {} ): CredentialProvider => { + // when set to true, metadata service will not fetch token + let disableFetchToken = false; const { timeout, maxRetries } = providerConfigFromInit(init); - return async () => { + + const getCredentials = async ( + maxRetries: number, + options: RequestOptions + ) => { const profile = ( - await retry( - async () => - ( - await httpRequest({ host: IMDS_IP, path: IMDS_PATH, timeout }) - ).toString(), - maxRetries - ) + await retry(async () => { + let profile: string; + try { + profile = await getProfile(options); + } catch (err) { + if (err.statusCode === 401) { + disableFetchToken = false; + } + throw err; + } + return profile; + }, maxRetries) ).trim(); return retry(async () => { - const credsResponse = JSON.parse( - ( - await httpRequest({ - host: IMDS_IP, - path: IMDS_PATH + profile, - timeout - }) - ).toString() - ); - if (!isImdsCredentials(credsResponse)) { - throw new ProviderError( - "Invalid response received from instance metadata service." - ); + let creds: Credentials; + try { + creds = await getCredentialsFromProfile(profile, options); + } catch (err) { + if (err.statusCode === 401) { + disableFetchToken = false; + } + throw err; } - - return fromImdsCredentials(credsResponse); + return creds; }, maxRetries); }; + + return async () => { + if (disableFetchToken) { + return getCredentials(maxRetries, { timeout }); + } else { + let token: string; + try { + token = (await getMetadataToken({ timeout })).toString(); + } catch (error) { + if (error?.statusCode === 400) { + throw Object.assign(error, { + message: "EC2 Metadata token request returned error" + }); + } else if ( + error.message === "TimeoutError" || + [403, 404, 405].includes(error.statusCode) + ) { + disableFetchToken = true; + } + return getCredentials(maxRetries, { timeout }); + } + return getCredentials(maxRetries, { + timeout, + headers: { + "x-aws-ec2-metadata-token": token + } + }); + } + }; +}; + +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 getCredentialsFromProfile = async ( + profile: string, + options: RequestOptions +) => { + const credsResponse = JSON.parse( + ( + await httpRequest({ + ...options, + host: IMDS_IP, + path: IMDS_PATH + profile + }) + ).toString() + ); + + if (!isImdsCredentials(credsResponse)) { + throw new ProviderError( + "Invalid response received from instance metadata service." + ); + } + + return fromImdsCredentials(credsResponse); }; diff --git a/packages/credential-provider-imds/src/remoteProvider/httpRequest.spec.ts b/packages/credential-provider-imds/src/remoteProvider/httpRequest.spec.ts index a05a8377d70a..a66bf63a1845 100644 --- a/packages/credential-provider-imds/src/remoteProvider/httpRequest.spec.ts +++ b/packages/credential-provider-imds/src/remoteProvider/httpRequest.spec.ts @@ -92,4 +92,18 @@ describe("httpRequest", () => { [300, 400, 500].forEach(errorOnStatusCode); }); }); + + it("timeout", async () => { + const timeout = 1000; + const scope = nock(`http://${host}:${port}`) + .get(path) + .delay(timeout * 2) + .reply(200, "expectedResponse"); + + await expect( + httpRequest({ host, path, port, timeout }) + ).rejects.toStrictEqual(new Error("TimeoutError")); + + scope.done(); + }); }); diff --git a/packages/credential-provider-imds/src/remoteProvider/httpRequest.ts b/packages/credential-provider-imds/src/remoteProvider/httpRequest.ts index 4eeed8ad17f0..25d8b0d4c105 100644 --- a/packages/credential-provider-imds/src/remoteProvider/httpRequest.ts +++ b/packages/credential-provider-imds/src/remoteProvider/httpRequest.ts @@ -10,18 +10,28 @@ export function httpRequest(options: RequestOptions): Promise { const req = request({ method: "GET", ...options }); req.on("error", err => { reject( - new ProviderError("Unable to connect to instance metadata service") + Object.assign( + new ProviderError("Unable to connect to instance metadata service"), + err + ) ); }); + req.on("timeout", () => { + reject(new Error("TimeoutError")); + }); + req.on("response", (res: IncomingMessage) => { const { statusCode = 400 } = res; if (statusCode < 200 || 300 <= statusCode) { - const error = new ProviderError( - "Error response received from instance metadata service" + reject( + Object.assign( + new ProviderError( + "Error response received from instance metadata service" + ), + { statusCode } + ) ); - (error as any).statusCode = statusCode; - reject(error); } const chunks: Array = [];