Skip to content

Validate token endpoints of user-supplied external_account credentials - #1627

Open
akkaur wants to merge 1 commit into
developfrom
fix-b501543027-external-account-validation
Open

akkaur wants to merge 1 commit into
developfrom
fix-b501543027-external-account-validation

Conversation

@akkaur

@akkaur akkaur commented Oct 5, 2026

Copy link
Copy Markdown

Issue

The GCP plugins accept a service account credential as inline JSON or a file path and load it with GoogleCredentials.fromStream, which accepts any credential type the auth library supports, including external_account (workload identity federation).

An external_account configuration tells the auth library where to obtain a subject token (credential_source) and where to send it: the subject token is sent to token_url and the resulting access token to service_account_impersonation_url. When such a configuration comes from user input, these endpoints must be validated; otherwise the configuration could direct tokens to an arbitrary endpoint. See Google's guidance on validating credential configurations from external sources: https://cloud.google.com/docs/authentication/client-libraries#validate_other_credential_configurations

Fix

This change adds that validation in GCPUtils.loadServiceAccountCredentials, the single method all plugin credential paths (validation, connection test, deploy and runtime) go through. For configurations of type external_account:

• token_url must be exactly https://sts.googleapis.com/v1/token
• service_account_impersonation_url, if present, must be exactly https://iamcredentials.googleapis.com/v1/projects/-
/serviceAccounts/:generateAccessToken

With both endpoints pinned to Google's documented values, whatever the configuration reads can only be sent to Google. Non-conforming configurations are rejected with a validation error naming the field.

The JSON is parsed with the same parser GoogleCredentials.fromStream uses, so the validation sees exactly the document the library will act on.

A credential configuration of type external_account tells the auth
library where to read a subject token from (credential_source) and
where to send it: the subject token is POSTed to token_url and the
resulting access token to service_account_impersonation_url. When such
a configuration is pasted into the service account JSON field of a
plugin, a user with only validation access could point
credential_source at the Compute Engine metadata server and token_url
at an endpoint they control, causing the service agent access token of
the environment to be sent to them.

Following
https://cloud.google.com/docs/authentication/client-libraries#validate_other_credential_configurations
GCPUtils.loadServiceAccountCredentials now requires, for
external_account configurations, that
 - token_url is exactly https://sts.googleapis.com/v1/token
 - service_account_impersonation_url, if present, is exactly
   https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/<email>:generateAccessToken
With both endpoints pinned to Google, whatever the configuration reads
can only be sent to Google.

The configuration is parsed with the same parser that
GoogleCredentials.fromStream uses, so validation and the library see
exactly the same document and unparseable content fails with the
library's own error. The external_account credential generated by CDAP
for GKE Workload Identity and gcloud-generated configurations use these
exact endpoints and continue to load; service_account JSON and all
other content are handled by the library exactly as before.

Fixes b/501543027, b/501546932
@akkaur
akkaur requested review from sahusanket and vsethi09 October 5, 2026 20:39

@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 introduces validation for external account credentials in GCPUtils to prevent potential token theft and exfiltration attacks by ensuring that token and impersonation URLs match expected Google endpoints. It also adds comprehensive unit tests in GCPUtilsTest to verify these security constraints. The review feedback highlights a potential NullPointerException when parsing empty or null JSON configurations and suggests simplifying the test code by replacing a custom stream-reading helper with Guava's ByteStreams.toByteArray.

Comment on lines +161 to +165
GenericJson json = new JsonObjectParser(GsonFactory.getDefaultInstance())
.parseAndClose(new ByteArrayInputStream(credentialBytes), StandardCharsets.UTF_8, GenericJson.class);
if (!EXTERNAL_ACCOUNT_TYPE.equals(json.get("type"))) {
return;
}

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

If the parsed JSON is empty or null, JsonObjectParser.parseAndClose can return null. Calling json.get("type") directly without a null check would then result in a NullPointerException. Please add a null check for json before accessing its properties.

Suggested change
GenericJson json = new JsonObjectParser(GsonFactory.getDefaultInstance())
.parseAndClose(new ByteArrayInputStream(credentialBytes), StandardCharsets.UTF_8, GenericJson.class);
if (!EXTERNAL_ACCOUNT_TYPE.equals(json.get("type"))) {
return;
}
GenericJson json = new JsonObjectParser(GsonFactory.getDefaultInstance())
.parseAndClose(new ByteArrayInputStream(credentialBytes), StandardCharsets.UTF_8, GenericJson.class);
if (json == null || !EXTERNAL_ACCOUNT_TYPE.equals(json.get("type"))) {
return;
}

Comment on lines +72 to +74
try (InputStream is = exchange.getRequestBody()) {
attackerBody.set(new String(readAll(is), StandardCharsets.UTF_8));
}

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

Instead of using a custom readAll helper method, you can use Guava's ByteStreams.toByteArray(InputStream) which is already available on the classpath and used in the main source code. This simplifies the test code and avoids custom stream copying logic.

Suggested change
try (InputStream is = exchange.getRequestBody()) {
attackerBody.set(new String(readAll(is), StandardCharsets.UTF_8));
}
try (InputStream is = exchange.getRequestBody()) {
attackerBody.set(new String(com.google.common.io.ByteStreams.toByteArray(is), StandardCharsets.UTF_8));
}

Comment on lines +310 to +318
private static byte[] readAll(InputStream is) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int read;
while ((read = is.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
return out.toByteArray();
}

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

This custom readAll helper method can be completely removed if you use Guava's ByteStreams.toByteArray(InputStream) instead.

This branch has not been deployed

No deployments
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.

1 participant