-
Notifications
You must be signed in to change notification settings - Fork 39
fix(mint): reject cross-org installation mismatch #1332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -712,6 +712,10 @@ func (h *Handler) mintToken(ctx context.Context, org, role string, repos []strin | |
| // findInstallation looks up the app's installation ID via the repo-based API. | ||
| // Using GET /repos/{owner}/{repo}/installation instead of /orgs/{org}/installation | ||
| // enables per-repo app installations in the future. | ||
| // | ||
| // The returned installation's account is verified against the expected org to | ||
| // prevent cross-org token leakage when the same GitHub App is installed on | ||
| // multiple orgs (see issue #1321). | ||
| func (h *Handler) findInstallation(ctx context.Context, jwt, org, repo string) (int64, error) { | ||
| reqURL := fmt.Sprintf("%s/repos/%s/%s/installation", h.githubBaseURL, org, repo) | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) | ||
|
|
@@ -741,6 +745,17 @@ func (h *Handler) findInstallation(ctx context.Context, jwt, org, repo string) ( | |
| return 0, fmt.Errorf("no installation found for %s/%s", org, repo) | ||
| } | ||
|
|
||
| // Verify the installation belongs to the expected org. When the same | ||
| // GitHub App is installed on multiple orgs, the API could return an | ||
| // installation for the wrong org. Accepting it would mint a token | ||
| // scoped to a different org's repos. | ||
| if !strings.EqualFold(inst.Account.Login, org) { | ||
| log.Printf("installation org mismatch: expected %s, got %s (installation %d for %s/%s)", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — No structured alerting for this security-relevant event A cross-org installation mismatch is a security event (misconfigured App or potential exploitation attempt), but it's only logged via Consider tagging the log with a severity prefix (e.g., 2/10 review agents flagged this |
||
| org, inst.Account.Login, inst.ID, org, repo) | ||
| return 0, fmt.Errorf("installation for %s/%s belongs to %s, not %s", | ||
| org, repo, inst.Account.Login, org) | ||
| } | ||
|
|
||
| return inst.ID, nil | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1284,6 +1284,155 @@ func TestHandler_MultiOrg_WrongOrg(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| // TestHandler_CrossOrgInstallationMismatch reproduces the bug from issue #1321: | ||
| // an agent running in org-a (agentshed) gets a token that can write to repos | ||
| // in org-b (fullsend-ai). This happens when the same GitHub App is installed | ||
| // on both orgs and findInstallation returns an installation belonging to a | ||
| // different org than the OIDC-authenticated org. The mint must reject this. | ||
| func TestHandler_CrossOrgInstallationMismatch(t *testing.T) { | ||
| t.Setenv("ALLOWED_ORGS", "org-a,org-b") | ||
| t.Setenv("GCP_PROJECT_NUMBER", "123456") | ||
| t.Setenv("WIF_POOL_NAME", "test-pool") | ||
| t.Setenv("WIF_PROVIDER_NAME", "github-oidc") | ||
| t.Setenv("OIDC_AUDIENCE", "fullsend-mint") | ||
| // Same app ID for both orgs (shared GitHub App). | ||
| t.Setenv("ROLE_APP_IDS", `{"org-a/retro":"999","org-b/retro":"999"}`) | ||
| t.Setenv("ALLOWED_WORKFLOW_FILES", "*") | ||
|
|
||
| pemData, err := generateTestRSAKey() | ||
| if err != nil { | ||
| t.Fatalf("generating test key: %v", err) | ||
| } | ||
|
|
||
| // OIDC token is from org-a (the requesting org). | ||
| oidcToken := makeTestOIDCToken( | ||
| "https://token.actions.githubusercontent.com", | ||
| "fullsend-mint", | ||
| "org-a/.fullsend", | ||
| "org-a", | ||
| "org-a/.fullsend/.github/workflows/retro.yml@refs/heads/main", | ||
| time.Now().Add(10*time.Minute).Unix(), | ||
| ) | ||
|
|
||
| // Simulate GitHub returning an installation belonging to org-b instead | ||
| // of org-a. This could happen if the App isn't installed on org-a's copy | ||
| // of the repo, or if GitHub resolves the installation differently. | ||
| github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| switch { | ||
| case r.URL.Path == "/repos/org-a/seshi/installation" && r.Method == http.MethodGet: | ||
| // GitHub returns an installation, but the account is org-b. | ||
| json.NewEncoder(w).Encode(installationResponse{ | ||
| ID: 77777, Account: struct { | ||
| Login string `json:"login"` | ||
| }{Login: "org-b"}, | ||
| }) | ||
| case strings.HasPrefix(r.URL.Path, "/app/installations/77777/access_tokens") && r.Method == http.MethodPost: | ||
| // If we get here, the mint failed to catch the org mismatch. | ||
| w.WriteHeader(http.StatusCreated) | ||
| json.NewEncoder(w).Encode(installationTokenResponse{ | ||
| Token: "ghs_CROSS_ORG_TOKEN", | ||
| ExpiresAt: "2026-05-07T12:00:00Z", | ||
| }) | ||
| default: | ||
| t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) | ||
| w.WriteHeader(http.StatusNotFound) | ||
| } | ||
| })) | ||
| defer github.Close() | ||
|
|
||
| h := NewHandler(&fakePEMAccessor{ | ||
| pems: map[string][]byte{"org-a/retro": pemData}, | ||
| }, &fakeTokenValidator{}) | ||
| h.githubBaseURL = github.URL | ||
|
|
||
| body := `{"role":"retro","repos":["seshi"]}` | ||
| rec := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) | ||
| req.Header.Set("Authorization", "Bearer "+oidcToken) | ||
| h.ServeHTTP(rec, req) | ||
|
|
||
| // The mint MUST reject this — the installation belongs to org-b but the | ||
| // OIDC token authenticated org-a. A 502 (bad gateway) is appropriate | ||
| // since the upstream GitHub API returned an unexpected installation. | ||
| if rec.Code == http.StatusOK { | ||
| var resp mintResponse | ||
| json.NewDecoder(rec.Body).Decode(&resp) | ||
| t.Fatalf("mint should reject cross-org installation mismatch, but returned 200 with token=%s", resp.Token) | ||
| } | ||
| if rec.Code != http.StatusBadGateway { | ||
| t.Fatalf("expected 502 for cross-org installation mismatch, got %d: %s", rec.Code, rec.Body.String()) | ||
| } | ||
| } | ||
|
|
||
| // TestHandler_CrossOrgInstallation_SameOrgPasses verifies the positive case: | ||
| // when the installation account matches the OIDC org, minting succeeds. | ||
| func TestHandler_CrossOrgInstallation_SameOrgPasses(t *testing.T) { | ||
| t.Setenv("ALLOWED_ORGS", "org-a,org-b") | ||
| t.Setenv("GCP_PROJECT_NUMBER", "123456") | ||
| t.Setenv("WIF_POOL_NAME", "test-pool") | ||
| t.Setenv("WIF_PROVIDER_NAME", "github-oidc") | ||
| t.Setenv("OIDC_AUDIENCE", "fullsend-mint") | ||
| t.Setenv("ROLE_APP_IDS", `{"org-a/retro":"999","org-b/retro":"999"}`) | ||
| t.Setenv("ALLOWED_WORKFLOW_FILES", "*") | ||
|
|
||
| pemData, err := generateTestRSAKey() | ||
| if err != nil { | ||
| t.Fatalf("generating test key: %v", err) | ||
| } | ||
|
|
||
| oidcToken := makeTestOIDCToken( | ||
| "https://token.actions.githubusercontent.com", | ||
| "fullsend-mint", | ||
| "org-a/.fullsend", | ||
| "org-a", | ||
| "org-a/.fullsend/.github/workflows/retro.yml@refs/heads/main", | ||
| time.Now().Add(10*time.Minute).Unix(), | ||
| ) | ||
|
|
||
| github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| switch { | ||
| case r.URL.Path == "/repos/org-a/seshi/installation" && r.Method == http.MethodGet: | ||
| // Installation correctly belongs to org-a. | ||
| json.NewEncoder(w).Encode(installationResponse{ | ||
| ID: 88888, Account: struct { | ||
| Login string `json:"login"` | ||
| }{Login: "org-a"}, | ||
| }) | ||
| case strings.HasPrefix(r.URL.Path, "/app/installations/88888/access_tokens") && r.Method == http.MethodPost: | ||
| w.WriteHeader(http.StatusCreated) | ||
| json.NewEncoder(w).Encode(installationTokenResponse{ | ||
| Token: "ghs_correct_org_token", | ||
| ExpiresAt: "2026-05-07T12:00:00Z", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — Missing test for case-mismatch between The code uses 4/10 review agents flagged this |
||
| }) | ||
| default: | ||
| t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) | ||
| w.WriteHeader(http.StatusNotFound) | ||
| } | ||
| })) | ||
| defer github.Close() | ||
|
|
||
| h := NewHandler(&fakePEMAccessor{ | ||
| pems: map[string][]byte{"org-a/retro": pemData}, | ||
| }, &fakeTokenValidator{}) | ||
| h.githubBaseURL = github.URL | ||
|
|
||
| body := `{"role":"retro","repos":["seshi"]}` | ||
| rec := httptest.NewRecorder() | ||
| req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) | ||
| req.Header.Set("Authorization", "Bearer "+oidcToken) | ||
| h.ServeHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusOK { | ||
| t.Fatalf("expected 200 when installation matches OIDC org, got %d: %s", rec.Code, rec.Body.String()) | ||
| } | ||
|
|
||
| var resp mintResponse | ||
| json.NewDecoder(rec.Body).Decode(&resp) | ||
| if resp.Token != "ghs_correct_org_token" { | ||
| t.Fatalf("expected ghs_correct_org_token, got %s", resp.Token) | ||
| } | ||
| } | ||
|
|
||
| func TestResolveWIFProvider(t *testing.T) { | ||
| h := &Handler{ | ||
| defaultWIFProvider: "github-oidc", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MEDIUM — Threat model description overstates the risk
The comment says "the API could return an installation for the wrong org," but
GET /repos/{owner}/{repo}/installationconstructs the URL usingorgfrom the OIDC-validatedrepository_ownerclaim, so GitHub should only return the installation for that specific org (or 404). The scenario whereAccount.Logindiffers from{owner}is not documented GitHub behavior — realistic vectors are more like repo transfers or org renames, not routine cross-org installation leakage.The defense-in-depth check is still valuable and worth keeping, but consider clarifying the comment to say this guards against hypothetical API inconsistencies rather than a known, routinely exploitable path.
6/10 review agents flagged this