Skip to content

Commit 954ff24

Browse files
authored
feat(credential-provider-imds): signed IMDS workflow (#1358)
1 parent 8617911 commit 954ff24

File tree

4 files changed

+291
-51
lines changed

4 files changed

+291
-51
lines changed

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

+182-26
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,29 @@ jest.mock("./remoteProvider/retry");
1212
jest.mock("./remoteProvider/RemoteProviderInit");
1313

1414
describe("fromInstanceMetadata", () => {
15+
const host = "169.254.169.254";
1516
const mockTimeout = 1000;
1617
const mockMaxRetries = 3;
17-
const mockProfile = "foo";
18+
const mockToken = "fooToken";
19+
const mockProfile = "fooProfile";
1820

19-
const mockHttpRequestOptions = {
20-
host: "169.254.169.254",
21+
const mockTokenRequestOptions = {
22+
host,
23+
path: "/latest/api/token",
24+
method: "PUT",
25+
headers: {
26+
"x-aws-ec2-metadata-token-ttl-seconds": "21600",
27+
},
28+
timeout: mockTimeout,
29+
};
30+
31+
const mockProfileRequestOptions = {
32+
host,
2133
path: "/latest/meta-data/iam/security-credentials/",
2234
timeout: mockTimeout,
35+
headers: {
36+
"x-aws-ec2-metadata-token": mockToken,
37+
},
2338
};
2439

2540
const mockImdsCreds = Object.freeze({
@@ -48,35 +63,38 @@ describe("fromInstanceMetadata", () => {
4863
jest.resetAllMocks();
4964
});
5065

51-
it("gets profile name from IMDS, and passes profile name to fetch credentials", async () => {
52-
(httpRequest as jest.Mock).mockResolvedValueOnce(mockProfile).mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
66+
it("gets token and profile name to fetch credentials", async () => {
67+
(httpRequest as jest.Mock)
68+
.mockResolvedValueOnce(mockToken)
69+
.mockResolvedValueOnce(mockProfile)
70+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
5371

5472
(retry as jest.Mock).mockImplementation((fn: any) => fn());
5573
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
5674

5775
await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds);
58-
expect(httpRequest).toHaveBeenCalledTimes(2);
59-
expect(httpRequest).toHaveBeenNthCalledWith(1, mockHttpRequestOptions);
60-
expect(httpRequest).toHaveBeenNthCalledWith(2, {
61-
...mockHttpRequestOptions,
62-
path: `${mockHttpRequestOptions.path}${mockProfile}`,
76+
expect(httpRequest).toHaveBeenCalledTimes(3);
77+
expect(httpRequest).toHaveBeenNthCalledWith(1, mockTokenRequestOptions);
78+
expect(httpRequest).toHaveBeenNthCalledWith(2, mockProfileRequestOptions);
79+
expect(httpRequest).toHaveBeenNthCalledWith(3, {
80+
...mockProfileRequestOptions,
81+
path: `${mockProfileRequestOptions.path}${mockProfile}`,
6382
});
6483
});
6584

6685
it("trims profile returned name from IMDS", async () => {
6786
(httpRequest as jest.Mock)
87+
.mockResolvedValueOnce(mockToken)
6888
.mockResolvedValueOnce(" " + mockProfile + " ")
6989
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
7090

7191
(retry as jest.Mock).mockImplementation((fn: any) => fn());
7292
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
7393

7494
await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds);
75-
expect(httpRequest).toHaveBeenCalledTimes(2);
76-
expect(httpRequest).toHaveBeenNthCalledWith(1, mockHttpRequestOptions);
77-
expect(httpRequest).toHaveBeenNthCalledWith(2, {
78-
...mockHttpRequestOptions,
79-
path: `${mockHttpRequestOptions.path}${mockProfile}`,
95+
expect(httpRequest).toHaveBeenNthCalledWith(3, {
96+
...mockProfileRequestOptions,
97+
path: `${mockProfileRequestOptions.path}${mockProfile}`,
8098
});
8199
});
82100

@@ -107,7 +125,10 @@ describe("fromInstanceMetadata", () => {
107125
});
108126

109127
it("throws ProviderError if credentials returned are incorrect", async () => {
110-
(httpRequest as jest.Mock).mockResolvedValueOnce(mockProfile).mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
128+
(httpRequest as jest.Mock)
129+
.mockResolvedValueOnce(mockToken)
130+
.mockResolvedValueOnce(mockProfile)
131+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
111132

112133
(retry as jest.Mock).mockImplementation((fn: any) => fn());
113134
((isImdsCredentials as unknown) as jest.Mock).mockReturnValueOnce(false);
@@ -116,40 +137,175 @@ describe("fromInstanceMetadata", () => {
116137
new ProviderError("Invalid response received from instance metadata service.")
117138
);
118139
expect(retry).toHaveBeenCalledTimes(2);
119-
expect(httpRequest).toHaveBeenCalledTimes(2);
140+
expect(httpRequest).toHaveBeenCalledTimes(3);
120141
expect(isImdsCredentials).toHaveBeenCalledTimes(1);
121142
expect(isImdsCredentials).toHaveBeenCalledWith(mockImdsCreds);
122143
expect(fromImdsCredentials).not.toHaveBeenCalled();
123144
});
124145

125-
it("throws Error if requestFromEc2Imds for profile fails", async () => {
146+
it("throws Error if httpRequest for profile fails", async () => {
126147
const mockError = new Error("profile not found");
127-
(httpRequest as jest.Mock).mockRejectedValueOnce(mockError);
148+
(httpRequest as jest.Mock).mockResolvedValueOnce(mockToken).mockRejectedValueOnce(mockError);
128149
(retry as jest.Mock).mockImplementation((fn: any) => fn());
129150

130151
await expect(fromInstanceMetadata()()).rejects.toEqual(mockError);
131152
expect(retry).toHaveBeenCalledTimes(1);
132-
expect(httpRequest).toHaveBeenCalledTimes(1);
153+
expect(httpRequest).toHaveBeenCalledTimes(2);
133154
});
134155

135-
it("throws Error if requestFromEc2Imds for credentials fails", async () => {
156+
it("throws Error if httpRequest for credentials fails", async () => {
136157
const mockError = new Error("creds not found");
137-
(httpRequest as jest.Mock).mockResolvedValueOnce(mockProfile).mockRejectedValueOnce(mockError);
158+
(httpRequest as jest.Mock)
159+
.mockResolvedValueOnce(mockToken)
160+
.mockResolvedValueOnce(mockProfile)
161+
.mockRejectedValueOnce(mockError);
138162
(retry as jest.Mock).mockImplementation((fn: any) => fn());
139163

140164
await expect(fromInstanceMetadata()()).rejects.toEqual(mockError);
141165
expect(retry).toHaveBeenCalledTimes(2);
142-
expect(httpRequest).toHaveBeenCalledTimes(2);
166+
expect(httpRequest).toHaveBeenCalledTimes(3);
143167
expect(fromImdsCredentials).not.toHaveBeenCalled();
144168
});
145169

146-
it("throws SyntaxError if requestFromEc2Imds returns unparseable creds", async () => {
147-
(httpRequest as jest.Mock).mockResolvedValueOnce(mockProfile).mockResolvedValueOnce(".");
170+
it("throws SyntaxError if httpRequest returns unparseable creds", async () => {
171+
(httpRequest as jest.Mock)
172+
.mockResolvedValueOnce(mockToken)
173+
.mockResolvedValueOnce(mockProfile)
174+
.mockResolvedValueOnce(".");
148175
(retry as jest.Mock).mockImplementation((fn: any) => fn());
149176

150177
await expect(fromInstanceMetadata()()).rejects.toEqual(new SyntaxError("Unexpected token . in JSON at position 0"));
151178
expect(retry).toHaveBeenCalledTimes(2);
152-
expect(httpRequest).toHaveBeenCalledTimes(2);
179+
expect(httpRequest).toHaveBeenCalledTimes(3);
153180
expect(fromImdsCredentials).not.toHaveBeenCalled();
154181
});
182+
183+
it("throws error if metadata token errors with statusCode 400", async () => {
184+
const tokenError = Object.assign(new Error("token not found"), {
185+
statusCode: 400,
186+
});
187+
(httpRequest as jest.Mock).mockRejectedValueOnce(tokenError);
188+
189+
await expect(fromInstanceMetadata()()).rejects.toEqual(tokenError);
190+
});
191+
192+
describe("disables fetching of token", () => {
193+
beforeEach(() => {
194+
(retry as jest.Mock).mockImplementation((fn: any) => fn());
195+
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
196+
});
197+
198+
it("when token fetch returns with TimeoutError", async () => {
199+
const tokenError = new Error("TimeoutError");
200+
201+
(httpRequest as jest.Mock)
202+
.mockRejectedValueOnce(tokenError)
203+
.mockResolvedValueOnce(mockProfile)
204+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds))
205+
.mockResolvedValueOnce(mockProfile)
206+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
207+
208+
const fromInstanceMetadataFunc = fromInstanceMetadata();
209+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
210+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
211+
});
212+
213+
[403, 404, 405].forEach((statusCode) => {
214+
it(`when token fetch errors with statusCode ${statusCode}`, async () => {
215+
const tokenError = Object.assign(new Error(), { statusCode });
216+
217+
(httpRequest as jest.Mock)
218+
.mockRejectedValueOnce(tokenError)
219+
.mockResolvedValueOnce(mockProfile)
220+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds))
221+
.mockResolvedValueOnce(mockProfile)
222+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
223+
224+
const fromInstanceMetadataFunc = fromInstanceMetadata();
225+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
226+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
227+
});
228+
});
229+
});
230+
231+
it("uses insecure data flow once, if error is not TimeoutError", async () => {
232+
const tokenError = new Error("Error");
233+
234+
(httpRequest as jest.Mock)
235+
.mockRejectedValueOnce(tokenError)
236+
.mockResolvedValueOnce(mockProfile)
237+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds))
238+
.mockResolvedValueOnce(mockToken)
239+
.mockResolvedValueOnce(mockProfile)
240+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
241+
242+
(retry as jest.Mock).mockImplementation((fn: any) => fn());
243+
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
244+
245+
const fromInstanceMetadataFunc = fromInstanceMetadata();
246+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
247+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
248+
});
249+
250+
it("uses insecure data flow once, if error statusCode is not 400, 403, 404, 405", async () => {
251+
const tokenError = Object.assign(new Error("Error"), { statusCode: 406 });
252+
253+
(httpRequest as jest.Mock)
254+
.mockRejectedValueOnce(tokenError)
255+
.mockResolvedValueOnce(mockProfile)
256+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds))
257+
.mockResolvedValueOnce(mockToken)
258+
.mockResolvedValueOnce(mockProfile)
259+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
260+
261+
(retry as jest.Mock).mockImplementation((fn: any) => fn());
262+
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
263+
264+
const fromInstanceMetadataFunc = fromInstanceMetadata();
265+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
266+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
267+
});
268+
269+
describe("re-enables fetching of token", () => {
270+
const error401 = Object.assign(new Error("error"), { statusCode: 401 });
271+
272+
beforeEach(() => {
273+
const tokenError = new Error("TimeoutError");
274+
275+
(httpRequest as jest.Mock)
276+
.mockRejectedValueOnce(tokenError)
277+
.mockResolvedValueOnce(mockProfile)
278+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
279+
280+
(retry as jest.Mock).mockImplementation((fn: any) => fn());
281+
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);
282+
});
283+
284+
it("when profile error with 401", async () => {
285+
(httpRequest as jest.Mock)
286+
.mockRejectedValueOnce(error401)
287+
.mockResolvedValueOnce(mockToken)
288+
.mockResolvedValueOnce(mockProfile)
289+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
290+
291+
const fromInstanceMetadataFunc = fromInstanceMetadata();
292+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
293+
await expect(fromInstanceMetadataFunc()).rejects.toEqual(error401);
294+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
295+
});
296+
297+
it("when creds error with 401", async () => {
298+
(httpRequest as jest.Mock)
299+
.mockResolvedValueOnce(mockProfile)
300+
.mockRejectedValueOnce(error401)
301+
.mockResolvedValueOnce(mockToken)
302+
.mockResolvedValueOnce(mockProfile)
303+
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));
304+
305+
const fromInstanceMetadataFunc = fromInstanceMetadata();
306+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
307+
await expect(fromInstanceMetadataFunc()).rejects.toEqual(error401);
308+
await expect(fromInstanceMetadataFunc()).resolves.toEqual(mockCreds);
309+
});
310+
});
155311
});

0 commit comments

Comments
 (0)