Skip to content

Commit 701aacb

Browse files
committed
Add role assumption cycle detection
1 parent d6c545c commit 701aacb

File tree

2 files changed

+154
-72
lines changed

2 files changed

+154
-72
lines changed

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

Lines changed: 113 additions & 41 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,13 +61,11 @@ 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-
() => { throw new Error('The promise should have been rejected.'); },
61-
err => {
62-
expect((err as CredentialError).tryNextLink).toBe(true);
63-
}
64-
);
64+
it('should flag a lack of credentials as a non-terminal error', () => {
65+
return expect(fromIni()()).rejects.toMatchObject({
66+
message: 'Profile default could not be found or parsed in shared credentials file.',
67+
tryNextLink: true,
68+
});
6569
});
6670

6771
describe('shared credentials file', () => {
@@ -475,7 +479,7 @@ source_profile = default`.trim()
475479

476480
it(
477481
'should reject the promise with a terminal error if no role assumer provided',
478-
async () => {
482+
() => {
479483
__addMatcher(join(homedir(), '.aws', 'credentials'), `
480484
[default]
481485
aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
@@ -487,18 +491,16 @@ role_arn = arn:aws:iam::123456789:role/foo
487491
source_profile = bar`.trim()
488492
);
489493

490-
await fromIni({profile: 'foo'})().then(
491-
() => { throw new Error('The promise should have been rejected'); },
492-
err => {
493-
expect((err as any).tryNextLink).toBeFalsy();
494-
}
495-
);
494+
return expect(fromIni({profile: 'foo'})()).rejects.toMatchObject({
495+
message: 'Profile foo requires a role to be assumed, but no role assumption callback was provided.',
496+
tryNextLink: false,
497+
});
496498
}
497499
);
498500

499501
it(
500502
'should reject the promise if the source profile cannot be found',
501-
async () => {
503+
() => {
502504
__addMatcher(join(homedir(), '.aws', 'credentials'), `
503505
[default]
504506
aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
@@ -510,10 +512,15 @@ role_arn = arn:aws:iam::123456789:role/foo
510512
source_profile = bar`.trim()
511513
);
512514

513-
await fromIni({profile: 'foo'})().then(
514-
() => { throw new Error('The promise should have been rejected'); },
515-
() => { /* Promise rejected as expected */ }
516-
);
515+
const provider = fromIni({
516+
profile: 'foo',
517+
roleAssumer: jest.fn()
518+
});
519+
520+
return expect(provider()).rejects.toMatchObject({
521+
message: 'Profile bar could not be found or parsed in shared credentials file.',
522+
tryNextLink: false,
523+
});
517524
}
518525
);
519526

@@ -692,7 +699,7 @@ source_profile = default`.trim()
692699

693700
it(
694701
'should reject the promise with a terminal error if a MFA serial is present but no mfaCodeProvider was provided',
695-
async () => {
702+
() => {
696703
const roleArn = 'arn:aws:iam::123456789:role/foo';
697704
const mfaSerial = 'mfaSerial';
698705
__addMatcher(join(homedir(), '.aws', 'credentials'), `
@@ -712,12 +719,10 @@ source_profile = default`.trim()
712719
roleAssumer: () => Promise.resolve(FOO_CREDS),
713720
});
714721

715-
await provider().then(
716-
() => { throw new Error('The promise should have been rejected'); },
717-
err => {
718-
expect((err as any).tryNextLink).toBeFalsy();
719-
}
720-
);
722+
return expect(provider()).rejects.toMatchObject({
723+
message: 'Profile foo requires multi-factor authentication, but no MFA code callback was provided.',
724+
tryNextLink: false,
725+
});
721726
}
722727
);
723728
});
@@ -741,31 +746,31 @@ aws_session_token = ${FOO_CREDS.sessionToken}`.trim());
741746
}
742747
);
743748

744-
it('should reject credentials with no access key', async () => {
749+
it('should reject credentials with no access key', () => {
745750
__addMatcher(join(homedir(), '.aws', 'credentials'), `
746751
[default]
747752
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
748753
`.trim());
749754

750-
await fromIni()().then(
751-
() => { throw new Error('The promise should have been rejected'); },
752-
() => { /* Promise rejected as expected */ }
753-
);
755+
return expect(fromIni()()).rejects.toMatchObject({
756+
message: 'Profile default could not be found or parsed in shared credentials file.',
757+
tryNextLink: true,
758+
});
754759
});
755760

756-
it('should reject credentials with no secret key', async () => {
761+
it('should reject credentials with no secret key', () => {
757762
__addMatcher(join(homedir(), '.aws', 'credentials'), `
758763
[default]
759764
aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
760765
`.trim());
761766

762-
await fromIni()().then(
763-
() => { throw new Error('The promise should have been rejected'); },
764-
() => { /* Promise rejected as expected */ }
765-
);
767+
return expect(fromIni()()).rejects.toMatchObject({
768+
message: 'Profile default could not be found or parsed in shared credentials file.',
769+
tryNextLink: true,
770+
});
766771
});
767772

768-
it('should not merge profile values together', async () => {
773+
it('should not merge profile values together', () => {
769774
__addMatcher(join(homedir(), '.aws', 'credentials'), `
770775
[default]
771776
aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
@@ -776,9 +781,76 @@ aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
776781
aws_secret_access_key = ${FOO_CREDS.secretAccessKey}
777782
`.trim());
778783

779-
await fromIni()().then(
780-
() => { throw new Error('The promise should have been rejected'); },
781-
() => { /* Promise rejected as expected */ }
782-
);
784+
return expect(fromIni()()).rejects.toMatchObject({
785+
message: 'Profile default could not be found or parsed in shared credentials file.',
786+
tryNextLink: true,
787+
});
783788
});
789+
790+
it(
791+
'should treat a profile with static credentials and role assumption keys as an assume role profile',
792+
() => {
793+
__addMatcher(join(homedir(), '.aws', 'credentials'), `
794+
[default]
795+
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
796+
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
797+
role_arn = foo
798+
source_profile = foo
799+
800+
[foo]
801+
aws_access_key_id = ${FOO_CREDS.accessKeyId}
802+
aws_secret_access_key = ${FOO_CREDS.secretAccessKey}
803+
aws_session_token = ${FOO_CREDS.sessionToken}
804+
`.trim());
805+
806+
const provider = fromIni({
807+
roleAssumer(
808+
sourceCreds: Credentials,
809+
params: AssumeRoleParams
810+
): Promise<Credentials> {
811+
expect(sourceCreds).toEqual(FOO_CREDS);
812+
expect(params.RoleArn).toEqual('foo');
813+
814+
return Promise.resolve(FIZZ_CREDS);
815+
}
816+
});
817+
818+
return expect(provider()).resolves.toEqual(FIZZ_CREDS);
819+
}
820+
);
821+
822+
it(
823+
'should reject credentials when profile role assumption creates a cycle',
824+
() => {
825+
__addMatcher(join(homedir(), '.aws', 'credentials'), `
826+
[default]
827+
role_arn = foo
828+
source_profile = foo
829+
830+
[bar]
831+
role_arn = baz
832+
source_profile = baz
833+
834+
[fizz]
835+
role_arn = buzz
836+
source_profile = foo
837+
`.trim());
838+
839+
__addMatcher(join(homedir(), '.aws', 'config'), `
840+
[profile foo]
841+
role_arn = bar
842+
source_profile = bar
843+
844+
[profile baz]
845+
role_arn = fizz
846+
source_profile = fizz
847+
`.trim());
848+
const provider = fromIni({roleAssumer: jest.fn()});
849+
850+
return expect(provider()).rejects.toMatchObject({
851+
message: 'Detected a cycle attempting to resolve credentials for profile default. Profiles visited: foo, bar, baz, fizz',
852+
tryNextLink: false,
853+
});
854+
}
855+
);
784856
});

packages/credential-provider-ini/index.ts

Lines changed: 41 additions & 31 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 {
@@ -106,12 +105,9 @@ function isStaticCredsProfile(arg: any): arg is StaticCredsProfile {
106105
&& ['undefined', 'string'].indexOf(typeof arg.aws_session_token) > -1;
107106
}
108107

109-
interface AssumeRoleProfile {
108+
interface AssumeRoleProfile extends Profile{
110109
role_arn: string;
111110
source_profile: string;
112-
role_session_name?: string;
113-
external_id?: string;
114-
mfa_serial?: string;
115111
}
116112

117113
function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile {
@@ -128,28 +124,33 @@ function isAssumeRoleProfile(arg: any): arg is AssumeRoleProfile {
128124
* role assumption and multi-factor authentication.
129125
*/
130126
export function fromIni(init: FromIniInit = {}): CredentialProvider {
131-
return () => parseKnownFiles(init).then(profiles => {
132-
const {
133-
profile = process.env[ENV_PROFILE] || DEFAULT_PROFILE,
134-
} = init;
127+
return () => parseKnownFiles(init).then(profiles => resolveProfileData(
128+
getMasterProfileName(init),
129+
profiles,
130+
init
131+
));
132+
}
135133

136-
return resolveProfileData(profile, profiles, init);
137-
});
134+
function getMasterProfileName(init: FromIniInit): string {
135+
return init.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE;
138136
}
139137

140138
async function resolveProfileData(
141139
profileName: string,
142140
profiles: ParsedIniData,
143-
options: FromIniInit
141+
options: FromIniInit,
142+
visitedProfiles: {[profileName: string]: true} = {}
144143
): Promise<Credentials> {
145144
const data = profiles[profileName];
146-
if (isStaticCredsProfile(data)) {
147-
return Promise.resolve({
148-
accessKeyId: data.aws_access_key_id,
149-
secretAccessKey: data.aws_secret_access_key,
150-
sessionToken: data.aws_session_token,
151-
});
152-
} else if (isAssumeRoleProfile(data)) {
145+
if (isAssumeRoleProfile(data)) {
146+
const {
147+
external_id: ExternalId,
148+
mfa_serial,
149+
role_arn: RoleArn,
150+
role_session_name: RoleSessionName = 'aws-sdk-js-' + Date.now(),
151+
source_profile
152+
} = data;
153+
153154
if (!options.roleAssumer) {
154155
throw new CredentialError(
155156
`Profile ${profileName} requires a role to be assumed, but no` +
@@ -158,18 +159,21 @@ async function resolveProfileData(
158159
);
159160
}
160161

161-
const {
162-
external_id: ExternalId,
163-
mfa_serial,
164-
role_arn: RoleArn,
165-
role_session_name: RoleSessionName = 'aws-sdk-js-' + Date.now(),
166-
source_profile,
167-
} = data;
162+
if (source_profile in visitedProfiles) {
163+
throw new CredentialError(
164+
`Detected a cycle attempting to resolve credentials for profile`
165+
+ ` ${getMasterProfileName(options)}. Profiles visited: `
166+
+ Object.keys(visitedProfiles).join(', '),
167+
false
168+
);
169+
}
168170

169-
const sourceCreds = fromIni({
170-
...options,
171-
profile: source_profile,
172-
})();
171+
const sourceCreds = resolveProfileData(
172+
source_profile,
173+
profiles,
174+
options,
175+
{...visitedProfiles, [source_profile]: true}
176+
);
173177
const params: AssumeRoleParams = {RoleArn, RoleSessionName, ExternalId};
174178
if (mfa_serial) {
175179
if (!options.mfaCodeProvider) {
@@ -184,6 +188,12 @@ async function resolveProfileData(
184188
}
185189

186190
return options.roleAssumer(await sourceCreds, params);
191+
} else if (isStaticCredsProfile(data)) {
192+
return Promise.resolve({
193+
accessKeyId: data.aws_access_key_id,
194+
secretAccessKey: data.aws_secret_access_key,
195+
sessionToken: data.aws_session_token,
196+
});
187197
}
188198

189199
throw new CredentialError(

0 commit comments

Comments
 (0)