fix: enforce allowed-account-ids on all auth paths (#1847)

* fix: enforce allowed-account-ids on all auth paths

The allowed-account-ids list was only enforced in some auth flows. This
was due to the check being included in validateCredentials, which was
skipped if (GITHUB_ACTIONS && AccessKeyId && output-env-credentials) ->
false.

This unifies credential validation into a single path.

- validateCredentials(credentials?, ...) resolves credentials, proves
  liveness via one GetCallerIdentity call, and returns the identity.
- validateAccountId(expectedAccountIds, account) is now a pure comparison
  against the resolved account, enforced against the final (assumed)
  account independent of auth method, GITHUB_ACTIONS, or
  output-env-credentials.
- exportAccountId(identity, ...) consumes the resolved identity instead of
  making its own GetCallerIdentity call, so credential resolution happens
  exactly once per credential set.

Pre-assume account checks remain gated on !roleToAssume so cross-account
assume-role (source account differs from the role's target) is preserved.

Adds regression tests for the OIDC wrong-account case (the previously
missing negative test), OIDC with output-env-credentials: false, and the
assume-role wrong-account case.

* chore: move validateAccountId into helpers
This commit is contained in:
Tom Keller
2026-06-26 13:33:19 -07:00
committed by GitHub
parent e004cdcd28
commit 4d281fbc56
5 changed files with 184 additions and 86 deletions
+34 -33
View File
@@ -53,54 +53,55 @@ export class CredentialsClient {
public get stsClient(): STSClient {
if (!this._stsClient || this.roleChaining) {
this._stsClient = new STSClient({
customUserAgent: buildCustomUserAgent(),
...(this.region !== undefined && { region: this.region }),
...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }),
...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }),
});
this._stsClient = this.createStsClient();
}
return this._stsClient;
}
// Builds an STS client using the action's configured region/endpoint/proxy. When explicit credentials are provided,
// the client uses them directly instead of the SDK default credential provider chain.
// This matters for validateAccountId.
private createStsClient(credentials?: AwsCredentialIdentity): STSClient {
return new STSClient({
customUserAgent: buildCustomUserAgent(),
...(this.region !== undefined && { region: this.region }),
...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }),
...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }),
...(credentials !== undefined && { credentials }),
});
}
// Validates that the credentials the action will hand to subsequent steps actually work, and returns the resolved
// caller identity (account + ARN). "Work" is proven by a sts:GetCallerIdentity call, which both confirms the
// credentials are accepted by AWS and returns the identity for later checks and outputs to use.
public async validateCredentials(
credentials?: AwsCredentialIdentity,
expectedAccessKeyId?: string,
roleChaining?: boolean,
expectedAccountIds?: string[],
) {
let credentials: AwsCredentialIdentity;
try {
credentials = await this.loadCredentials();
if (!credentials.accessKeyId) {
throw new Error('Access key ID empty after loading credentials');
}
} catch (error) {
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
}
if (expectedAccountIds && expectedAccountIds.length > 0 && expectedAccountIds[0] !== '') {
let callerIdentity: Awaited<ReturnType<typeof getCallerIdentity>>;
): Promise<Awaited<ReturnType<typeof getCallerIdentity>>> {
if (!credentials) {
let resolved: AwsCredentialIdentity;
try {
callerIdentity = await getCallerIdentity(this.stsClient);
resolved = await this.loadCredentials();
if (!resolved.accessKeyId) {
throw new Error('Access key ID empty after loading credentials');
}
} catch (error) {
throw new Error(`Could not validate account ID of credentials: ${errorMessage(error)}`);
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
}
if (!callerIdentity.Account || !expectedAccountIds.includes(callerIdentity.Account)) {
throw new Error(
`The account ID of the provided credentials (${
callerIdentity.Account ?? 'unknown'
}) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`,
);
}
}
if (!roleChaining) {
const actualAccessKeyId = credentials.accessKeyId;
if (expectedAccessKeyId && expectedAccessKeyId !== actualAccessKeyId) {
if (!roleChaining && expectedAccessKeyId && expectedAccessKeyId !== resolved.accessKeyId) {
throw new Error(
'Credentials loaded by the SDK do not match the expected access key ID configured by the action',
);
}
}
const client = credentials ? this.createStsClient(credentials) : this.stsClient;
try {
return await getCallerIdentity(client);
} catch (error) {
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
}
}
private async loadCredentials() {