Skip to content

fix(api): validate registration credentials and GCP WIF endpoint URLs - #1414

Merged
IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:fix/credential-validation
Oct 25, 2026
Merged

IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:fix/credential-validation

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Credentials can be submitted two ways: with a registration (POST /api/register, saved when an admin approves the registration) and through POST /api/accounts/:id/credentials. The second path checks the credential type and the payload shape. The first path didn't, so a registration's credential was saved exactly as sent. This PR runs the same checks on both paths. It also adds an endpoint check for GCP workload identity federation (WIF) configs, which runs when a config is saved and again before a stored config is used.

This complements #1308, which pins the JSON type each stored GCP credential slot may hold. The two branches merge cleanly, and the combined tree passes go test -race ./internal/credentials/ ./internal/api/.

What is checked now

Registration credentials (internal/api/handler_registrations_credentials.go)

  • credential_type and credential_payload must be sent together or not at all. Registrations without credentials are still accepted.
  • credential_type must be one of the four known types, and it must belong to the registration's provider (aws_access_keys→aws, azure_client_secret→azure, gcp_*→gcp).
  • credential_payload must be a JSON object that passes validateCredentialPayload, the same check POST /api/accounts/:id/credentials runs.
  • The payload is re-encoded from the checked object, so the stored bytes are exactly the ones that were checked.
  • On approval, the stored credential is decrypted and checked again against the provider of the account being created. This happens before the registration leaves pending. A credential that fails gets a 400 telling the admin to reject the registration. Nothing is transitioned, created or saved. Before this change, a decrypt failure or a payload with no type was logged and skipped, and the account was still created and enabled.
  • Without a credential store, the payload was already dropped at submit time. The type is now dropped with it.

GCP WIF endpoints (internal/credentials/gcp_endpoints.go, used by validateGCPWIFPayload)

  • token_url is required and must be https://sts.googleapis.com/....
  • service_account_impersonation_url is optional. When set, it must be a non-empty string of the form https://iamcredentials.googleapis.com/....
  • Neither URL may have user info or an explicit port, and the host must match exactly. That rejects look-alike hosts such as sts.googleapis.com.example.net and my-sts.googleapis.com, http:// URLs, and IP literals.
  • loadStoredGCPTokenSource runs the same check on any stored external_account JSON before building a token source. It decodes the JSON the same way golang.org/x/oauth2/google does (case-insensitive keys, last duplicate wins), so it checks the value that library would use. A failing config makes the load return a clear error that names the field but not the stored value. The load does not panic or fall back. A stored WIF config still takes priority over the secret-free federated path, and the check covers that case.

Compatibility

  • Who could be affected: stored or submitted GCP WIF configs whose token_url or service_account_impersonation_url points anywhere other than Google's public STS / IAM Credentials endpoints, for example a private or regional STS endpoint, a proxy, or a URL with an explicit :443. gcloud-generated configs and the gcp-target IaC (iac/federation/gcp-target/terraform/registration.tf) use exactly https://sts.googleapis.com/v1/token and https://iamcredentials.googleapis.com/..., and both pass (tested). An affected account's GCP calls fail with "stored gcp workload identity config for account … is not usable: …; save the config again" until the config is saved again with Google endpoints.
  • Registrations that send a credential which POST /api/accounts/:id/credentials would also reject now get a 400 at submit. Pending registrations that already hold such a credential can't be approved. The admin gets a 400 and can reject the registration and ask for a new one.
  • Secret-free federated GCP accounts, application-default, Azure and AWS paths are unchanged, apart from the registration checks above.

Verification

All commands were run from the worktree on Linux, with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. Nothing calls a real cloud API: building a token source makes no network calls, and no test calls Token().

  • go build ./... && go vet ./... && go vet -tags integration ./... && (cd tests/e2e && go vet -tags e2e ./...): clean
  • golangci-lint run v2.10.1 on ./internal/api/... ./internal/credentials/..., with and without --build-tags integration: 0 issues
  • gocyclo -over 10 on the changed non-test files: none
  • go test -race -short -count=1 ./internal/api/ ./internal/credentials/: ok
  • go test -race -tags integration -count=1 -timeout 45m ./internal/api/ ./internal/credentials/: credentials ok. api: every test passed except TestIntegration_HistorySummary_TotalsCoverAllInScopeRows, whose Postgres test container did not become ready within the timeout. The same test fails the same way on unmodified origin/main on this VM (Docker was heavily loaded at the time), and it does not touch the code changed here. CI's Integration Tests job should confirm it.
  • Fail-before / pass-after: I put the new tests on a tree where the non-test part of the fix was reverted. handler_registrations.go, validation.go and resolver.go were restored from origin/main, the new handler file was removed, and ValidateGCPWIFEndpoints was made a no-op. On that tree, every new rejection test failed (35 failing api subtests, 76 failing credentials subtests). The acceptance and compatibility tests passed before and after: no credentials, gcloud-style configs, and the empty / malformed load errors. With the fix, all new tests pass.
  • Cases covered: look-alike hosts (sts.googleapis.com.example.net, my-sts.googleapis.com, trailing dot, other Google host), http:// and other schemes, user info (sts.googleapis.com@host, user:pass@), explicit ports (:443, :8443, empty), IP literals, case-variant and duplicate keys, a valid gcloud-generated WIF config (AWS aws1 source and file source), registrations with a mismatched provider/type, junk/array/null/string JSON, type-only or payload-only submissions, credentials omitted (still accepted), and approval of a pending registration whose stored credential fails (400, nothing created).
  • secrets-scan.sh on the changed files: "clean; planted key flagged". scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

🤖 Generated with Claude Code

IAmJSD and others added 3 commits October 25, 2026 16:18
A stored external_account config names the endpoints the resolver talks
to when it exchanges and impersonates tokens. Only Google's STS and IAM
Credentials endpoints are ever expected there (gcloud and the gcp-target
IaC write exactly those, and the secret-free federated path hard-codes
them), so the resolver now checks them before it builds a token source:
token_url must be https://sts.googleapis.com/..., and
service_account_impersonation_url, when set, must be
https://iamcredentials.googleapis.com/..., with no user info or port.

The check decodes the JSON the same way golang.org/x/oauth2/google does,
so it sees the values that library would use. A config that fails it
makes the load return a clear error naming the field, without the stored
value, instead of a token source.

ValidateGCPWIFEndpoints is exported so the API can apply the same rule
when a config is submitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
validateGCPWIFPayload checked the shape of a gcp_workload_identity_config
but not its URLs. It now runs credentials.ValidateGCPWIFEndpoints on
token_url and service_account_impersonation_url, the same rule the
resolver applies before using a stored config, so a config that could
never be used is refused with a 400 when it is saved. The optional
impersonation URL must also be a non-empty string when present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/register accepts an optional credential_type and
credential_payload, which approval saves into account_credentials. They
were stored as sent, while POST /api/accounts/:id/credentials checks the
type and the payload shape. Registrations now get the same checks:

- credential_type and credential_payload come together or not at all;
- the type must be a known one and belong to the registration's
  provider;
- the payload must be a JSON object that passes
  validateCredentialPayload (including the GCP WIF endpoint check).

The stored payload is re-encoded from the checked object, so the saved
bytes are the ones that were checked. Registrations without credentials
are accepted as before.

Approval checks the stored credential again, against the provider of the
account being created, before the registration leaves "pending". A
credential that fails stops the approval with a 400 telling the admin to
reject the registration; nothing is created or saved. Previously a
decrypt failure or a type-less payload was skipped with a log line while
the account was still created and enabled.

Without a credential store the payload was already dropped at submit;
the type is now dropped with it so the record stays consistent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p1 Next up; this sprint type/security Security finding labels Oct 25, 2026
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Opened at head 7d1fb80. There are three commits: the endpoint check in the credentials resolver (238633a), the same check in the WIF payload validation (3f818a3), and the registration credential checks on submit and approval (7d1fb80). Local verification is in the PR body.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 7d1fb80ad4a8d305038a9e2f3cb1f0549d39903b

Blocking:

  • none

Non-blocking:

  • internal/credentials/gcp_endpoints.go:73 (checkStoredGCPEndpoints): only type == "external_account" is checked. Everything else goes straight to google.CredentialsFromJSON (oauth2 v0.36.0), which also accepts impersonated_service_account (with a nested source_credentials that can itself be an external_account with any token_url and credential_source.file) and external_account_authorized_user (with its own token_url). The API never accepted those types on POST /api/accounts/:id/credentials. The registration path, however, stored and saved any JSON before this PR, so a row approved before deploy can still hold one, and loading it bypasses the new check. Concrete case: a stored impersonated_service_account whose source is {"type":"external_account","token_url":"https://evil/...","credential_source":{"file":"/proc/self/environ"}} still POSTs that file to evil. Suggested fix: reject any type other than the slot's expected one (service_account for the SA slot, external_account for the WIF slot) before parsing. It is small, and it makes the "checked before use" claim hold for every stored row.
  • Same SSRF class, out of this PR's stated scope: gcp_service_account still accepts an arbitrary token_uri (internal/api/validation.go:192), and the JWT flow POSTs the signed assertion there. credential_source.url, region_url and imdsv2_session_token_url are not checked either. The gcp-target IaC itself uses http://169.254.169.254/... for them, so pinning those needs a separate decision. I'd file both as a follow-up issue.
  • The PR links no issue (no Closes/Refs in the body or commits, and closingIssuesReferences is empty), so I judged it against its own body. A tracking issue, or Refs to sec(api/auth): low-severity authorization-surface hardening from the 2026-09-02 audit #310/chore(auth): medium/low findings from the 2026-07-28 full review #104 if the owner sees them as related, would help triage.
  • registrationCredential returns a 500 (credential store not configured) when a pending registration holds a payload but the handler has no credStore. Before, approval went ahead without the credential. This can only happen if the store config changes between submit and approve, so it is minor, but a 400/409 with an actionable message would read better than a 500.

Attacked:

  • Case and duplicate key smuggling. API path: rejectUnknownKeys rejects Token_URL/ſervice_... variants, and map decode gives last-duplicate-wins, the same as oauth2's struct decode. The registration re-encode sorts keys so that the exact lowercase key comes last. Stored path: struct decode with the same tags as credentialsFile in oauth2 v0.36.0 google/google.go, so both read the same value. I checked the IaC payload in iac/federation/gcp-target/terraform/registration.tf (both branches) against the new rules. Approval re-checks against the admin-supplied provider before TransitionRegistrationStatus, and the tests assert that nothing is transitioned, created or saved.
  • Fail-before: restored resolver.go, handler_registrations.go and validation.go from origin/main, removed handler_registrations_credentials.go and kept the tests. TestSubmitRegistration_RejectsInvalidCredential, _NoCredentialStoreRecordsNoCredential, TestApproveRegistration_RefusesInvalidStoredCredential, TestValidateCredentialPayload_GCPWIFEndpoints, TestLoadStoredGCPTokenSource_WIF_RejectsBadEndpoints, _ChecksTheValueTheParserUses and _ServiceAccountSlot_ChecksExternalAccountEndpoints all FAIL. With the head restored, they pass.
  • Ran go build ./... and go vet (plain, -tags integration, and e2e), all clean. go test -race -short -count=1 ./internal/api/ ./internal/credentials/ passed. go test -race -tags integration -count=1 passed on ./internal/credentials/ and on ./internal/api/ with -run 'Regist|Credential|GCP|WIF'. golangci-lint v2.10.1, with and without --build-tags integration, reported 0 issues. gocyclo -over 10 on the changed files was silent.

Body-stale:

  • "loadStoredGCPTokenSource runs the same check on any stored external_account JSON": accurate as written. It does not cover other stored types that oauth2 also parses (see the first non-blocking item).

Reviewer: rB8. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 7d1fb80ad4a8d305038a9e2f3cb1f0549d39903b

Blocking:

  • none

Non-blocking:

  • internal/api/handler_registrations_credentials.go:80-83: a decrypt failure (or a stored payload with no credential store configured) returns a plain error, so the admin gets a 500 with no hint to reject the registration. The 400 guidance only covers payloads that fail the shape check. It still fails closed (registration stays pending, nothing created), so this isn't a safety problem, but a NewClientError(400, ...reject the registration...) would match the PR's stated intent.
  • internal/api/validation.go:191-193 (unchanged): gcp_service_account still accepts any token_uri, and google.CredentialsFromJSON POSTs the signed JWT assertion there. That's a blind outbound request to a URL a registrant picks. The JWT is signed with the registrant's own key, so nothing secret leaks, but it is the same class of endpoint check this PR adds for WIF and it's reachable from the same unauthenticated POST /api/register (after approval). Worth a follow-up issue.
  • credential_source URLs and file paths stay unrestricted. That's by design (the gcp-target IaC itself uses IMDS URLs and a host token file). With token_url and the impersonation URL now pinned to Google, whatever they read can only go to Google STS, which closes the exfiltration path that mattered.
  • From an earlier review (rB8), which I agree with: checkStoredGCPEndpoints (internal/credentials/gcp_endpoints.go:73) only checks type == "external_account", but google.CredentialsFromJSON also parses impersonated_service_account (whose nested source_credentials can be an external_account with any token_url) and external_account_authorized_user. The API now refuses those types on both write paths, so only rows written before this PR (for example by the old unvalidated registration path) could hold one. Non-blocking, but an allow-list on type in the resolver would close it.
  • No linked issue, and the body has no closing keyword, which is fine for a hardening PR.

Attacked:

  • Read the full three-dot diff. Checked that the re-check runs before TransitionRegistrationStatus (so the registration stays pending), that it runs against the provider in the approval body rather than the registrant's claim, that the canonical re-encoding is what gets encrypted and stored, and that case-variant/duplicate token_url keys can't bypass anything (the API map check rejects unknown keys case-sensitively, and the resolver decodes with the same struct semantics as oauth2/google). Confirmed iac/federation/gcp-target/terraform/registration.tf payloads (aws1 and file sources) pass the new rules.
  • Ran: go build ./..., go vet with and without -tags integration on internal/api and internal/credentials, go test -race -short -count=1 ./internal/api/ ./internal/credentials/ (ok), go test -race -tags integration ./internal/credentials/ (ok, no integration tests there), golangci-lint v2.10.1 with and without integration tags (0 issues), gocyclo -over 10 on changed files (none). I did not run the 45-minute api integration suite; no integration test touches the changed code.
  • Fail-before: replaced the approval re-check in registrationCredential with a pass-through, and TestApproveRegistration_RefusesInvalidStoredCredential failed. Disabled the checkStoredGCPEndpoints call in resolver.go, and the WIF_RejectsBadEndpoints, WIF_ChecksTheValueTheParserUses and ServiceAccountSlot_ChecksExternalAccountEndpoints tests failed. Restored both afterwards.

Body-stale:

  • none found. One small imprecision: "A credential that fails gets a 400" holds for shape failures, but a decrypt failure gets a 500 (see above).

Reviewer: rB17. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 7d1fb80ad4a8d305038a9e2f3cb1f0549d39903b merged onto main (de56f95b), together with #1114, #1142, #1254, #1274, #1278, #1279, #1280, #1282, #1414 in that order (combined tree e1bf26a0, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 22:38
@IAmJSD
IAmJSD merged commit 5a2e31e into LeanerCloud:main Oct 25, 2026
36 checks passed
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on 7d1fb80a (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly 7d1fb80ad4a8d305038a9e2f3cb1f0549d39903b.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Conflict: internal/api/handler_registrations.go, end of
validateRegistrationRequest. Kept both: this branch's normalized contact
email (registrationContactEmail, stored as the bare address) and main's
LeanerCloud#1414 registration credential validation (parseRegistrationCredential).
They touch different fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority/p1 Next up; this sprint type/security Security finding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant