Skip to content

Commit 3839b79

Browse files
committed
Add role assumption cycle detection
1 parent de383af commit 3839b79

File tree

2 files changed

+159
-90
lines changed

2 files changed

+159
-90
lines changed

packages/credential-provider-ini/__tests__/index.ts

Lines changed: 122 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ const FOO_CREDS = {
2727
sessionToken: "baz"
2828
};
2929

30+
const FIZZ_CREDS = {
31+
accessKeyId: "fizz",
32+
secretAccessKey: "buzz",
33+
sessionToken: "pop"
34+
};
35+
3036
const envAtLoadTime: { [key: string]: string } = [
3137
ENV_CONFIG_PATH,
3238
ENV_CREDENTIALS_PATH,
@@ -55,15 +61,12 @@ afterAll(() => {
5561
});
5662

5763
describe("fromIni", () => {
58-
it("should flag a lack of credentials as a non-terminal error", async () => {
59-
await fromIni()().then(
60-
() => {
61-
throw new Error("The promise should have been rejected.");
62-
},
63-
err => {
64-
expect((err as CredentialError).tryNextLink).toBe(true);
65-
}
66-
);
64+
it("should flag a lack of credentials as a non-terminal error", () => {
65+
return expect(fromIni()()).rejects.toMatchObject({
66+
message:
67+
"Profile default could not be found or parsed in shared credentials file.",
68+
tryNextLink: true
69+
});
6770
});
6871

6972
describe("shared credentials file", () => {
@@ -491,7 +494,7 @@ source_profile = default`.trim()
491494
expect(await provider()).toEqual(FOO_CREDS);
492495
});
493496

494-
it("should reject the promise with a terminal error if no role assumer provided", async () => {
497+
it("should reject the promise with a terminal error if no role assumer provided", () => {
495498
__addMatcher(
496499
join(homedir(), ".aws", "credentials"),
497500
`
@@ -505,17 +508,14 @@ role_arn = arn:aws:iam::123456789:role/foo
505508
source_profile = bar`.trim()
506509
);
507510

508-
await fromIni({ profile: "foo" })().then(
509-
() => {
510-
throw new Error("The promise should have been rejected");
511-
},
512-
err => {
513-
expect((err as any).tryNextLink).toBeFalsy();
514-
}
515-
);
511+
return expect(fromIni({ profile: "foo" })()).rejects.toMatchObject({
512+
message:
513+
"Profile foo requires a role to be assumed, but no role assumption callback was provided.",
514+
tryNextLink: false
515+
});
516516
});
517517

518-
it("should reject the promise if the source profile cannot be found", async () => {
518+
it("should reject the promise if the source profile cannot be found", () => {
519519
__addMatcher(
520520
join(homedir(), ".aws", "credentials"),
521521
`
@@ -529,14 +529,16 @@ role_arn = arn:aws:iam::123456789:role/foo
529529
source_profile = bar`.trim()
530530
);
531531

532-
await fromIni({ profile: "foo" })().then(
533-
() => {
534-
throw new Error("The promise should have been rejected");
535-
},
536-
() => {
537-
/* Promise rejected as expected */
538-
}
539-
);
532+
const provider = fromIni({
533+
profile: "foo",
534+
roleAssumer: jest.fn()
535+
});
536+
537+
return expect(provider()).rejects.toMatchObject({
538+
message:
539+
"Profile bar could not be found or parsed in shared credentials file.",
540+
tryNextLink: false
541+
});
540542
});
541543

542544
it("should allow a profile in ~/.aws/credentials to use a source profile from ~/.aws/config", async () => {
@@ -716,7 +718,7 @@ source_profile = default`.trim()
716718
expect(await provider()).toEqual(FOO_CREDS);
717719
});
718720

719-
it("should reject the promise with a terminal error if a MFA serial is present but no mfaCodeProvider was provided", async () => {
721+
it("should reject the promise with a terminal error if a MFA serial is present but no mfaCodeProvider was provided", () => {
720722
const roleArn = "arn:aws:iam::123456789:role/foo";
721723
const mfaSerial = "mfaSerial";
722724
__addMatcher(
@@ -738,14 +740,11 @@ source_profile = default`.trim()
738740
roleAssumer: () => Promise.resolve(FOO_CREDS)
739741
});
740742

741-
await provider().then(
742-
() => {
743-
throw new Error("The promise should have been rejected");
744-
},
745-
err => {
746-
expect((err as any).tryNextLink).toBeFalsy();
747-
}
748-
);
743+
return expect(provider()).rejects.toMatchObject({
744+
message:
745+
"Profile foo requires multi-factor authentication, but no MFA code callback was provided.",
746+
tryNextLink: false
747+
});
749748
});
750749
});
751750

@@ -771,7 +770,7 @@ aws_session_token = ${FOO_CREDS.sessionToken}`.trim()
771770
expect(await fromIni()()).toEqual(DEFAULT_CREDS);
772771
});
773772

774-
it("should reject credentials with no access key", async () => {
773+
it("should reject credentials with no access key", () => {
775774
__addMatcher(
776775
join(homedir(), ".aws", "credentials"),
777776
`
@@ -780,17 +779,14 @@ aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
780779
`.trim()
781780
);
782781

783-
await fromIni()().then(
784-
() => {
785-
throw new Error("The promise should have been rejected");
786-
},
787-
() => {
788-
/* Promise rejected as expected */
789-
}
790-
);
782+
return expect(fromIni()()).rejects.toMatchObject({
783+
message:
784+
"Profile default could not be found or parsed in shared credentials file.",
785+
tryNextLink: true
786+
});
791787
});
792788

793-
it("should reject credentials with no secret key", async () => {
789+
it("should reject credentials with no secret key", () => {
794790
__addMatcher(
795791
join(homedir(), ".aws", "credentials"),
796792
`
@@ -799,17 +795,14 @@ aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
799795
`.trim()
800796
);
801797

802-
await fromIni()().then(
803-
() => {
804-
throw new Error("The promise should have been rejected");
805-
},
806-
() => {
807-
/* Promise rejected as expected */
808-
}
809-
);
798+
return expect(fromIni()()).rejects.toMatchObject({
799+
message:
800+
"Profile default could not be found or parsed in shared credentials file.",
801+
tryNextLink: true
802+
});
810803
});
811804

812-
it("should not merge profile values together", async () => {
805+
it("should not merge profile values together", () => {
813806
__addMatcher(
814807
join(homedir(), ".aws", "credentials"),
815808
`
@@ -826,13 +819,81 @@ aws_secret_access_key = ${FOO_CREDS.secretAccessKey}
826819
`.trim()
827820
);
828821

829-
await fromIni()().then(
830-
() => {
831-
throw new Error("The promise should have been rejected");
832-
},
833-
() => {
834-
/* Promise rejected as expected */
822+
return expect(fromIni()()).rejects.toMatchObject({
823+
message:
824+
"Profile default could not be found or parsed in shared credentials file.",
825+
tryNextLink: true
826+
});
827+
});
828+
829+
it("should treat a profile with static credentials and role assumption keys as an assume role profile", () => {
830+
__addMatcher(
831+
join(homedir(), ".aws", "credentials"),
832+
`
833+
[default]
834+
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
835+
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
836+
role_arn = foo
837+
source_profile = foo
838+
839+
[foo]
840+
aws_access_key_id = ${FOO_CREDS.accessKeyId}
841+
aws_secret_access_key = ${FOO_CREDS.secretAccessKey}
842+
aws_session_token = ${FOO_CREDS.sessionToken}
843+
`.trim()
844+
);
845+
846+
const provider = fromIni({
847+
roleAssumer(
848+
sourceCreds: Credentials,
849+
params: AssumeRoleParams
850+
): Promise<Credentials> {
851+
expect(sourceCreds).toEqual(FOO_CREDS);
852+
expect(params.RoleArn).toEqual("foo");
853+
854+
return Promise.resolve(FIZZ_CREDS);
835855
}
856+
});
857+
858+
return expect(provider()).resolves.toEqual(FIZZ_CREDS);
859+
});
860+
861+
it("should reject credentials when profile role assumption creates a cycle", () => {
862+
__addMatcher(
863+
join(homedir(), ".aws", "credentials"),
864+
`
865+
[default]
866+
role_arn = foo
867+
source_profile = foo
868+
869+
[bar]
870+
role_arn = baz
871+
source_profile = baz
872+
873+
[fizz]
874+
role_arn = buzz
875+
source_profile = foo
876+
`.trim()
836877
);
878+
879+
__addMatcher(
880+
join(homedir(), ".aws", "config"),
881+
`
882+
[profile foo]
883+
role_arn = bar
884+
source_profile = bar
885+
886+
[profile baz]
887+
role_arn = fizz
888+
source_profile = fizz
889+
`.trim()
890+
);
891+
const provider = fromIni({ roleAssumer: jest.fn() });
892+
893+
return expect(provider()).rejects.toMatchObject({
894+
message:
895+
"Detected a cycle attempting to resolve credentials for profile default. Profiles visited: foo, bar, baz, fizz",
896+
tryNextLink: false
897+
});
837898
});
838899
});

packages/credential-provider-ini/index.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,9 @@ interface ParsedIniData {
9393
[key: string]: Profile;
9494
}
9595

96-
interface StaticCredsProfile {
96+
interface StaticCredsProfile extends Profile {
9797
aws_access_key_id: string;
9898
aws_secret_access_key: string;
99-
aws_session_token?: string;
10099
}
101100

102101
function isStaticCredsProfile(arg: any): arg is StaticCredsProfile {
@@ -109,12 +108,9 @@ function isStaticCredsProfile(arg: any): arg is StaticCredsProfile {
109108
);
110109
}
111110

112-
interface AssumeRoleProfile {
111+
interface AssumeRoleProfile extends Profile {
113112
role_arn: string;
114113
source_profile: string;
115-
role_session_name?: string;
116-
external_id?: string;
117-
mfa_serial?: string;
118114
}
119115

120116
function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile {
@@ -135,26 +131,31 @@ function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile {
135131
*/
136132
export function fromIni(init: FromIniInit = {}): CredentialProvider {
137133
return () =>
138-
parseKnownFiles(init).then(profiles => {
139-
const { profile = process.env[ENV_PROFILE] || DEFAULT_PROFILE } = init;
134+
parseKnownFiles(init).then(profiles =>
135+
resolveProfileData(getMasterProfileName(init), profiles, init)
136+
);
137+
}
140138

141-
return resolveProfileData(profile, profiles, init);
142-
});
139+
function getMasterProfileName(init: FromIniInit): string {
140+
return init.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE;
143141
}
144142

145143
async function resolveProfileData(
146144
profileName: string,
147145
profiles: ParsedIniData,
148-
options: FromIniInit
146+
options: FromIniInit,
147+
visitedProfiles: { [profileName: string]: true } = {}
149148
): Promise<Credentials> {
150149
const data = profiles[profileName];
151-
if (isStaticCredsProfile(data)) {
152-
return Promise.resolve({
153-
accessKeyId: data.aws_access_key_id,
154-
secretAccessKey: data.aws_secret_access_key,
155-
sessionToken: data.aws_session_token
156-
});
157-
} else if (isAssumeRoleProfile(data)) {
150+
if (isAssumeRoleProfile(data)) {
151+
const {
152+
external_id: ExternalId,
153+
mfa_serial,
154+
role_arn: RoleArn,
155+
role_session_name: RoleSessionName = "aws-sdk-js-" + Date.now(),
156+
source_profile
157+
} = data;
158+
158159
if (!options.roleAssumer) {
159160
throw new CredentialError(
160161
`Profile ${profileName} requires a role to be assumed, but no` +
@@ -163,18 +164,19 @@ async function resolveProfileData(
163164
);
164165
}
165166

166-
const {
167-
external_id: ExternalId,
168-
mfa_serial,
169-
role_arn: RoleArn,
170-
role_session_name: RoleSessionName = "aws-sdk-js-" + Date.now(),
171-
source_profile
172-
} = data;
167+
if (source_profile in visitedProfiles) {
168+
throw new CredentialError(
169+
`Detected a cycle attempting to resolve credentials for profile` +
170+
` ${getMasterProfileName(options)}. Profiles visited: ` +
171+
Object.keys(visitedProfiles).join(", "),
172+
false
173+
);
174+
}
173175

174-
const sourceCreds = fromIni({
175-
...options,
176-
profile: source_profile
177-
})();
176+
const sourceCreds = resolveProfileData(source_profile, profiles, options, {
177+
...visitedProfiles,
178+
[source_profile]: true
179+
});
178180
const params: AssumeRoleParams = { RoleArn, RoleSessionName, ExternalId };
179181
if (mfa_serial) {
180182
if (!options.mfaCodeProvider) {
@@ -189,6 +191,12 @@ async function resolveProfileData(
189191
}
190192

191193
return options.roleAssumer(await sourceCreds, params);
194+
} else if (isStaticCredsProfile(data)) {
195+
return Promise.resolve({
196+
accessKeyId: data.aws_access_key_id,
197+
secretAccessKey: data.aws_secret_access_key,
198+
sessionToken: data.aws_session_token
199+
});
192200
}
193201

194202
throw new CredentialError(

0 commit comments

Comments
 (0)