feat: Allow custom session tags to be passed when assuming a role (#1759)

* Add possibility to input custom session tags

* Use json for input to custom-tags, add documentation for custom-tags

* Add more examples

* Simplify example to avoid parse error

* Add input validation for custom tags

* Fix unit tests for custom-tags

* Add debugging message

* Skip failing test for now

* Build package

* Remove some unused validation for custom tags

* feat: add validation for custom session tags

Harden the custom-tags feature against misuse and
misconfiguration:

- Validate input is a JSON object (reject arrays, primitives, null)
- Enforce STS tag constraints: key length (128), value length (256),
  allowed characters
- Reject nested object/array values that would silently stringify to
  '[object Object]'
- Block overriding default session tags (GitHub, Repository,
  Workflow, etc.)
- Enforce 50-tag session limit
- Warn when custom-tags used with OIDC or web identity
- Fix missing await on helpers test assertion
- Remove unused CUSTOM_TAGS_JSON_INPUTS fixture
- Normalize test mocking to vi.mocked() pattern

---------

Co-authored-by: Sylvain Verly <sylvain.verly@gmail.com>
This commit is contained in:
Tom Keller
2026-05-06 15:22:56 -07:00
committed by GitHub
parent c36525a567
commit 61f50f630f
7 changed files with 225 additions and 3 deletions
+76 -1
View File
@@ -78,6 +78,73 @@ export interface assumeRoleParams {
webIdentityToken?: string;
inlineSessionPolicy?: string;
managedSessionPolicies?: { arn: string }[];
customTags?: string;
}
const TAG_KEY_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u;
const TAG_VALUE_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u;
const MAX_TAG_KEY_LENGTH = 128;
const MAX_TAG_VALUE_LENGTH = 256;
const MAX_SESSION_TAGS = 50;
export function parseAndValidateCustomTags(customTags: string, existingTags: Tag[]): Tag[] {
let parsed: unknown;
try {
parsed = JSON.parse(customTags);
} catch {
throw new Error('custom-tags: input is not valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('custom-tags: input must be a JSON object (not an array or primitive)');
}
const reservedKeys = new Set(existingTags.map((tag) => tag.Key));
const newTags: Tag[] = [];
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'object') {
throw new Error(
`custom-tags: value for key '${key}' must be a string, number, or boolean (not an object or array)`,
);
}
const stringValue = String(value);
if (key.length === 0 || key.length > MAX_TAG_KEY_LENGTH) {
throw new Error(`custom-tags: key '${key}' must be between 1 and ${MAX_TAG_KEY_LENGTH} characters`);
}
if (stringValue.length > MAX_TAG_VALUE_LENGTH) {
throw new Error(
`custom-tags: value for key '${key}' exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
);
}
if (!TAG_KEY_REGEX.test(key)) {
throw new Error(
`custom-tags: key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
);
}
if (stringValue.length > 0 && !TAG_VALUE_REGEX.test(stringValue)) {
throw new Error(
`custom-tags: value for key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
);
}
if (reservedKeys.has(key)) {
throw new Error(
`custom-tags: key '${key}' conflicts with a default session tag set by this action and cannot be overridden`,
);
}
newTags.push({ Key: key, Value: stringValue });
}
if (existingTags.length + newTags.length > MAX_SESSION_TAGS) {
throw new Error(
`custom-tags: total session tags (${existingTags.length + newTags.length}) would exceed the AWS limit of ${MAX_SESSION_TAGS}`,
);
}
return newTags;
}
export async function assumeRole(params: assumeRoleParams) {
@@ -94,6 +161,7 @@ export async function assumeRole(params: assumeRoleParams) {
webIdentityToken,
inlineSessionPolicy,
managedSessionPolicies,
customTags,
} = { ...params };
// Load GitHub environment variables
@@ -111,17 +179,24 @@ export async function assumeRole(params: assumeRoleParams) {
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
{ Key: 'Commit', Value: GITHUB_SHA },
];
if (process.env.GITHUB_REF) {
tagArray.push({
Key: 'Branch',
Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
});
}
if (customTags) {
const parsed = parseAndValidateCustomTags(customTags, tagArray);
tagArray.push(...parsed);
}
const tags = roleSkipSessionTagging ? undefined : tagArray;
if (!tags) {
core.debug('Role session tagging has been skipped.');
} else {
core.debug(`${tags.length} role session tags are being used.`);
core.debug(`${tags.length} role session tags are being used:`);
}
//only populate transitiveTagKeys array if user is actually using session tagging
+9
View File
@@ -43,6 +43,7 @@ export async function run() {
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false });
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
const customTags = core.getInput('custom-tags', { required: false });
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
return { arn: p };
@@ -190,6 +191,13 @@ export async function run() {
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
}
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
core.warning(
"'custom-tags' is set but will be ignored because session tags cannot be applied when using OIDC or web identity token authentication. " +
'Tags are controlled by the identity provider token claims in these authentication flows.',
);
}
// Get role credentials if configured to do so
if (roleToAssume) {
let roleCredentials: AssumeRoleCommandOutput;
@@ -209,6 +217,7 @@ export async function run() {
webIdentityToken,
inlineSessionPolicy,
managedSessionPolicies,
customTags,
});
},
!disableRetry,