Skip to content

Commit 2bc37b1

Browse files
tadasantclauderdimitrov
authored
fix(auth): grant org namespace only to org Owners, not all members (#1383)
## The bug (scoped to `github-at`) When you exchange a GitHub OAuth/PAT token for a registry JWT, the handler granted `io.github.<org>/*` publish permission for **every** organization you belong to. It derived that list from [`GET /users/{username}/orgs`](https://docs.github.com/en/rest/orgs/orgs#list-organizations-for-a-user), which: 1. returns only **public** org memberships, and 2. carries **no role** — an org Owner and a brand-new member look identical. So membership was silently treated as publishing authority. Anyone who is (or whose public membership lists them as) a member of `acme` could publish — and, because publish is a pure namespace-glob with no per-server ownership check, **overwrite** — any server under `io.github.acme/*`. ## The fix Switch to [`GET /user/memberships/orgs`](https://docs.github.com/en/rest/orgs/members#list-organization-memberships-for-the-authenticated-user), which returns the **authenticated caller's role** per org (and includes private memberships), and grant the org namespace only when the role is `admin` (org Owner). - **Personal namespaces are unchanged** — you always get `io.github.<your-username>/*`. - **Org namespaces now require Owner.** GitHub has no org-level "maintainer" role; org membership is either `admin` (Owner) or `member`. We grant only on `admin`. - **`read:org` is the only scope needed.** A minimal token without it still authenticates and still publishes to your personal namespace; a genuine missing-scope `403` from the memberships call is treated as "no admin orgs" rather than a hard failure, so we don't push anyone to over-scope a token. A `403`/`429` that is actually a rate limit (`X-RateLimit-Remaining: 0` / `Retry-After`) fails closed instead, so a throttle can't silently strip a real Owner's org grant. Critically, the token needs **no repository scopes** — the registry never reads or writes code, so an org Owner can hand CI a near-powerless `read:org` token. The authorization decision stays stateless and delegated to GitHub — no new storage, no new endpoints, no schema changes — consistent with [design principle #2](docs/design/design-principles.md) (minimal operational burden). ### Containing the CI token The registry-side change makes the org Owner the only one who can mint org-namespace authority. But if that Owner drops the resulting PAT into a plain repo secret, **every repository writer** can reach it (via branch/tag pushes that run repo-controlled build/test code in the privileged job) — quietly re-widening publish access beyond Owners. External fork contributors still cannot reach it by default (no secrets on fork PRs, no branch push), barring misconfigurations like `pull_request_target`-with-checkout or self-hosted runners on public repos. `docs/modelcontextprotocol-io/github-actions.mdx` documents how to contain that blast radius: - store the token as a **GitHub Actions Environment secret** (the PAT example uses `environment:`), - restrict that environment to the default branch / release tags and require PR reviews on it, so only reviewed, maintainer-approved code can reach the token, - optionally add a **required environment reviewer** for explicit per-publish approval, - and avoid the two footguns (`pull_request_target` checkout, self-hosted runners on public repos). This shrinks *who holds* the org credential; it does not change the fact that an authorized publish can still overwrite a different server in the same namespace until per-server publisher ownership exists (the follow-up noted below). ## Why this shape, given the alternatives [Pree's writeup](#982 (comment)) ([Notion analysis doc](https://www.notion.so/MCP-Registry-Authorization-Model-Analysis-Issue-982-31736f79f74980bc90c6e53de1c564c5)) is the clearest framing of the problem and the option space, laying out three broad solutions: - **Approach A — GitHub App as the authority layer.** Strong model, but its natural unit of authority is a *repo*, so it needs a repo→server mapping to authorize a namespace. The safe, flexible version of that mapping is registry-managed state — which collapses into Approach B. It's also GitHub-only. - **Approach B — a registry-managed grants table** (OIDC bootstrap, or device-flow admin check). The most capable and provider-agnostic option, and probably where v1 wants to go. But it introduces exactly the "registry manages an authorization database" layer we wanted to avoid taking on right now: revocation, sync, audit, migration. - **Approach C — scoped bearer tokens (npm-style).** Good ergonomics, but again implies issuance/storage the registry would own. The two-property test (does the registry control the decision? is it granted through an act requiring real authority?) is the right lens. This change satisfies it **without** new state: the registry makes the decision (it filters on role), and the authority is real (live GitHub Owner role, re-checked on every short-lived token exchange). **What this deliberately gives up**, so reviewers can weigh it honestly: - **Owners are always in the loop.** Only org Owners can publish org servers; there's no delegated "this team can publish this server" — that needs Approach B's state. Reduced flexibility in exchange for zero new infrastructure. - **Blast radius within an org is unchanged.** An Owner (or Owner-scoped CI token) can still overwrite *any* server under the org namespace, because there's still no per-server publisher ownership. That hijack-an-existing-server problem is real but **orthogonal** to this fix and belongs in its own change (record publisher identity per server; enforce it on update) — a follow-up to take alongside the OIDC hardening below. - **OIDC is untouched and remains coarser.** The `github-oidc` path still grants the whole `io.github.<owner>/*` namespace to any workflow with `id-token: write`, i.e. effectively anyone with write access to any repo in the org, and it excludes non-Actions CI (Jenkins/GitLab can't mint GitHub Actions OIDC). The admin-PAT path here is the higher-assurance option for org publishing; OIDC stays as a lower-assurance Actions convenience to be tightened separately. ## Revocation The registry JWT is **5-minute, no-refresh** (per #264/#273), so loss of Owner status propagates within minutes on the next token exchange, with no revocation list to maintain. ## Tests `go test ./internal/api/handlers/v0/auth/...` passes, including: - member-role org is **not** granted; only admin orgs are - a missing-scope `403` (no `read:org`) still yields the personal namespace and no error - an admin org on a **later page** of memberships is still granted (pagination correctness) - a `5xx` from the memberships call **fails closed** (no token issued) rather than degrading to personal-only - a rate-limit `403` (`X-RateLimit-Remaining: 0` / `Retry-After`) **fails closed** — not mistaken for a missing-scope `403` - existing org/name-validation tests updated to the new endpoint + role shape `gofmt`/`go vet`/`golangci-lint` (CI-pinned v2.11.4) clean; CI green on all checks. ## Live verification Confirmed end-to-end against real `api.github.com` (not just mocks) by exercising this branch's `ExchangeToken` and decoding the `permissions` claim of the returned JWT. A dedicated org, **Tadasant Test Org** (login `Tadasant-Test-Org`), is Owned by one account and has a second account joined as a plain **member**. Both memberships are `active`, verified via the same endpoint the fix uses (`GET /user/memberships/orgs/{org}`): | Token (role) | `io.github.Tadasant-Test-Org/*` granted? | Other patterns granted | |---|---|---| | Owner (`admin`) | ✅ yes | personal `io.github.<owner>/*` | | member | ❌ no | personal `io.github.<member>/*` + the orgs that account actually Owns | The member case is the meaningful one: that membership is **active and returned** by `GET /user/memberships/orgs`, and is excluded purely because its role is `member`, not `admin`. Under the previous `GET /users/{username}/orgs` behavior (membership = authority, no role), the same active membership **would have granted** the org namespace. The role filter is also selective rather than blanket-deny — the member account still receives the namespaces of the orgs where it genuinely holds Owner. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Radoslav Dimitrov <radoslav@stacklok.com>
1 parent 224e23d commit 2bc37b1

5 files changed

Lines changed: 573 additions & 37 deletions

File tree

docs/modelcontextprotocol-io/authentication.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ Successfully authenticated!
4747
✓ Successfully logged in
4848
```
4949

50+
### Personal vs. organization namespaces
51+
52+
GitHub authentication always grants your personal namespace, `io.github.<your-username>/*`.
53+
54+
To publish under an **organization** namespace (`io.github.<orgname>/*`), you must be an **Owner** of that organization. Ordinary org membership is no longer sufficient: the registry checks your membership role and only grants the org namespace to admins. This prevents anyone who merely belongs to an org from publishing — or overwriting — servers under the org's name.
55+
56+
If you authenticate with a Personal Access Token (for example in CI), the token must include the `read:org` scope for the registry to see your organization role. A token without `read:org` can still publish to your personal namespace, but org publishing will be silently unavailable. The token needs **no** repository scopes — the registry never reads or writes your code.
57+
5058
## DNS Authentication
5159

5260
DNS authentication is a domain-based authentication method that relies on a DNS TXT record.

docs/modelcontextprotocol-io/github-actions.mdx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ on:
8181
jobs:
8282
publish:
8383
runs-on: ubuntu-latest
84+
# Source MCP_GITHUB_TOKEN from a protected Environment, not a plain repo
85+
# secret, and restrict that environment to your default branch / release
86+
# tags. See "Securing your registry token in CI" below for why this matters.
87+
environment: mcp-registry-publish
8488
permissions:
8589
contents: read
8690

@@ -192,16 +196,33 @@ jobs:
192196
193197
## Step 2: Add Secrets
194198
195-
You may need to add a secret to the repository depending on which authentication method you choose:
199+
You may need to add a secret depending on which authentication method you choose:
196200
197201
- **GitHub OIDC Authentication**: No dedicated secret necessary.
198-
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token (PAT) that has `read:org` and `read:user` scopes.
202+
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token (PAT) that has the `read:org` scope. `read:org` is what lets the registry confirm you are an **Owner** of the organization before granting its namespace; the token needs **no** repository scopes (the registry never reads or writes your code), and no other scopes are required — `read:user` is not needed, and a bare token still authenticates and publishes to your personal namespace. For an **organization** token, store it as an **Environment secret** on the `mcp-registry-publish` environment rather than a plain repository secret — see "Securing your registry token in CI" below for why this matters and how to configure the environment.
199203
- **DNS Authentication**: Add a `MCP_PRIVATE_KEY` secret with your Ed25519 private key.
200204

201205
You may also need to add secrets for your package registry. For example, the workflow above needs an `NPM_TOKEN` secret with your npm token.
202206

203207
For information about how to add secrets to a repository, see [Using secrets in GitHub Actions](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets).
204208

209+
### Securing your registry token in CI
210+
211+
Whichever token you use to authenticate, treat it as a high-value credential. When you authenticate to an **organization** namespace, the resulting registry token can publish — and overwrite — **any** server under `io.github.<org>/*`, not just the one in this repository. The relevant question for your threat model is therefore: *who can cause code to run in a job where this secret is exposed?* By default that is **every repository writer** (via branch or tag pushes), not only org Owners — so a plain repo secret quietly widens publish access to everyone with write access.
212+
213+
GitHub gives you the tools to close that gap. We recommend, in increasing order of strength:
214+
215+
1. **Store the token as an [Environment secret](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), not a repository or organization secret.** A job can only read an environment secret when it declares `environment:` (as the PAT example above does). Note that the `environment:` key alone protects nothing: if the environment does not exist yet, GitHub **auto-creates it with no protection rules** on first run, and a job that declares `environment:` still also receives ordinary repository and organization secrets. The protection comes from storing the token *as a secret on that environment* and configuring the environment (steps 2–3 below) — not from the `environment:` key by itself.
216+
2. **Restrict that environment to your default branch and/or release tags** with a [deployment branch rule](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments#deployment-branches-and-tags), and protect that branch with [required pull request reviews](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches). Now only reviewed, maintainer-approved code can ever reach the token.
217+
3. **(Strongest) Add a [required reviewer](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments#required-reviewers) to the environment** so each publish pauses for an explicit human approval.
218+
219+
Two pitfalls to avoid regardless of the above:
220+
221+
- **Never publish from a `pull_request_target` workflow that checks out PR-head code.** That trigger runs with your secrets in the base-repo context, so an untrusted fork PR could exfiltrate the token. Environment branch rules do **not** protect you here (the ref is still your base branch) — only required reviewers do.
222+
- **Avoid self-hosted runners for the publish job on public repositories**, where fork PRs may be able to schedule jobs onto them.
223+
224+
By default, pull requests **from forks** receive no secrets and cannot push branches, so external contributors cannot reach the token without one of the misconfigurations above.
225+
205226
## Step 3: Tag and Release
206227

207228
Create and push a version tag to trigger the workflow:

internal/api/handlers/v0/auth/github_at.go

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"log"
78
"net/http"
89
"regexp"
910
"strings"
@@ -57,7 +58,7 @@ func RegisterGitHubATEndpoint(api huma.API, pathPrefix string, cfg *config.Confi
5758
}, func(ctx context.Context, input *GitHubTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
5859
response, err := handler.ExchangeToken(ctx, input.Body.GitHubToken)
5960
if err != nil {
60-
return nil, huma.Error401Unauthorized("Token exchange failed", err)
61+
return nil, tokenExchangeError(err)
6162
}
6263

6364
return &v0.Response[auth.TokenResponse]{
@@ -66,6 +67,17 @@ func RegisterGitHubATEndpoint(api huma.API, pathPrefix string, cfg *config.Confi
6667
})
6768
}
6869

70+
// tokenExchangeError maps an internal ExchangeToken failure to a client-facing
71+
// 401 without leaking internal detail. huma serializes extra error args into the
72+
// response body, and the wrapped err here can include the raw upstream GitHub
73+
// response body captured by readErrorBody — so passing it to huma would echo that
74+
// detail back to the caller (CWE-209). We log it server-side (like the sibling
75+
// handlers in servers.go) and return only a generic message.
76+
func tokenExchangeError(err error) error {
77+
log.Printf("github-at token exchange failed: %v", err)
78+
return huma.Error401Unauthorized("Token exchange failed")
79+
}
80+
6981
// ExchangeToken exchanges a GitHub OAuth token for a Registry JWT token
7082
func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (*auth.TokenResponse, error) {
7183
// Get GitHub user information
@@ -74,8 +86,9 @@ func (h *GitHubHandler) ExchangeToken(ctx context.Context, githubToken string) (
7486
return nil, fmt.Errorf("failed to get GitHub user: %w", err)
7587
}
7688

77-
// Get user's organizations
78-
orgs, err := h.getGitHubUserOrgs(ctx, user.Login, githubToken)
89+
// Get the organizations the user administers. Org namespaces are only granted
90+
// to org Owners (membership role "admin"), not to ordinary members.
91+
orgs, err := h.getGitHubAdminOrgs(ctx, githubToken)
7992
if err != nil {
8093
return nil, fmt.Errorf("failed to get GitHub organizations: %w", err)
8194
}
@@ -133,8 +146,87 @@ func (h *GitHubHandler) getGitHubUser(ctx context.Context, token string) (*GitHu
133146
return &user, nil
134147
}
135148

136-
func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, username string, token string) ([]GitHubUserOrOrg, error) {
137-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/users/"+username+"/orgs", nil)
149+
// githubOrgRoleAdmin is the membership role GitHub returns for an organization
150+
// Owner. It is the only role we treat as carrying publish authority for the org
151+
// namespace. GET /user/memberships/orgs reports the caller's role as either
152+
// "admin" (Owner) or "member" (GitHub has no org-level "maintainer" role); only
153+
// "admin" carries publish authority, so a "member" is intentionally not granted.
154+
const githubOrgRoleAdmin = "admin"
155+
156+
// githubMembershipStateActive is the membership state for an accepted (as opposed
157+
// to merely invited/pending) org membership. We only honor active memberships so a
158+
// not-yet-accepted owner invitation never grants the org namespace.
159+
const githubMembershipStateActive = "active"
160+
161+
// orgMembershipsPageSize is the page size we request when listing the user's org
162+
// memberships. 100 is GitHub's maximum.
163+
const orgMembershipsPageSize = 100
164+
165+
// maxOrgMembershipPages bounds the pagination loop. At orgMembershipsPageSize=100
166+
// this covers 10,000 org memberships — far beyond any real user — and exists only
167+
// as a backstop so a misbehaving or redirected upstream cannot drive an unbounded
168+
// number of requests.
169+
const maxOrgMembershipPages = 100
170+
171+
// githubOrgMembership is one entry from GET /user/memberships/orgs: the
172+
// authenticated user's membership in a single organization, including their role.
173+
type githubOrgMembership struct {
174+
State string `json:"state"`
175+
Role string `json:"role"`
176+
Organization GitHubUserOrOrg `json:"organization"`
177+
}
178+
179+
// getGitHubAdminOrgs returns the organizations in which the authenticated user is
180+
// an Owner (membership role "admin").
181+
//
182+
// This deliberately uses GET /user/memberships/orgs rather than
183+
// GET /users/{username}/orgs. The latter only returns *public* memberships and
184+
// carries no role, so it cannot distinguish an Owner from an ordinary member —
185+
// historically every org a user belonged to was granted publish access to its
186+
// namespace regardless of the user's role. The memberships endpoint returns the
187+
// caller's role per org (and includes private memberships), letting us grant the
188+
// org namespace only to people who actually administer the org.
189+
//
190+
// The endpoint requires the read:org scope. A minimal token used only for
191+
// personal-namespace publishing won't have it and will get a 403 here; that is
192+
// treated as "no admin orgs" (see fetchOrgMembershipsPage) so personal publishing
193+
// keeps working without asking users to over-scope their token.
194+
func (h *GitHubHandler) getGitHubAdminOrgs(ctx context.Context, token string) ([]GitHubUserOrOrg, error) {
195+
var adminOrgs []GitHubUserOrOrg
196+
197+
for page := 1; page <= maxOrgMembershipPages; page++ {
198+
memberships, err := h.fetchOrgMembershipsPage(ctx, token, page)
199+
if err != nil {
200+
return nil, err
201+
}
202+
203+
for _, m := range memberships {
204+
// state=active is already requested in the query string, but re-check it
205+
// here as defense in depth: if that param is ever dropped or changed, this
206+
// keeps a pending (not-yet-accepted) owner invitation from being granted
207+
// the org namespace.
208+
if m.State == githubMembershipStateActive && m.Role == githubOrgRoleAdmin {
209+
adminOrgs = append(adminOrgs, m.Organization)
210+
}
211+
}
212+
213+
// A short page is the last page: return the complete result.
214+
if len(memberships) < orgMembershipsPageSize {
215+
return adminOrgs, nil
216+
}
217+
}
218+
219+
// Every page up to the cap was full. A real user never has this many org
220+
// memberships, so rather than return a possibly-truncated set (which would
221+
// silently strip a legitimate Owner's grant), fail closed.
222+
return nil, fmt.Errorf("org memberships exceeded %d pages; refusing to issue a possibly-truncated grant", maxOrgMembershipPages)
223+
}
224+
225+
// fetchOrgMembershipsPage fetches a single page of the authenticated user's
226+
// active organization memberships.
227+
func (h *GitHubHandler) fetchOrgMembershipsPage(ctx context.Context, token string, page int) ([]githubOrgMembership, error) {
228+
url := fmt.Sprintf("%s/user/memberships/orgs?state=active&per_page=%d&page=%d", h.baseURL, orgMembershipsPageSize, page)
229+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
138230
if err != nil {
139231
return nil, fmt.Errorf("failed to create request: %w", err)
140232
}
@@ -145,44 +237,69 @@ func (h *GitHubHandler) getGitHubUserOrgs(ctx context.Context, username string,
145237

146238
resp, err := http.DefaultClient.Do(req)
147239
if err != nil {
148-
return nil, fmt.Errorf("failed to get user organizations: %w", err)
240+
return nil, fmt.Errorf("failed to get user organization memberships: %w", err)
149241
}
150242
defer resp.Body.Close()
151243

244+
// GitHub returns 403 here for two very different reasons, which we must not
245+
// conflate:
246+
// 1. The token lacks the read:org scope. This is the common, benign case for a
247+
// minimal personal-publishing token: GET /user still works, but org
248+
// memberships are forbidden. We degrade gracefully to "no admin orgs" so
249+
// personal-namespace publishing keeps working without over-scoping.
250+
// 2. Rate limiting. GitHub signals primary/secondary rate limits with 403 (or
251+
// 429), setting X-RateLimit-Remaining: 0 and/or Retry-After. Degrading here
252+
// would silently strip a legitimate Owner's org grant, so we fail closed
253+
// (return an error) rather than mistake a throttle for "not an admin".
254+
if resp.StatusCode == http.StatusForbidden {
255+
if resp.Header.Get("X-RateLimit-Remaining") == "0" || resp.Header.Get("Retry-After") != "" {
256+
return nil, fmt.Errorf("GitHub API rate limit exceeded while listing org memberships (status 403): %s", readErrorBody(resp.Body))
257+
}
258+
// A SAML/SSO-enforced org returns 403 with an X-GitHub-SSO header when the
259+
// token has not been SSO-authorized. That is an Owner being blocked, not a
260+
// missing scope, so fail closed rather than silently dropping the org grant
261+
// (which would degrade a legitimate Owner to personal-only with no signal).
262+
if resp.Header.Get("X-GitHub-SSO") != "" {
263+
return nil, fmt.Errorf("GitHub org memberships require SSO authorization for this token (status 403): %s", readErrorBody(resp.Body))
264+
}
265+
return nil, nil
266+
}
267+
152268
if resp.StatusCode != http.StatusOK {
153269
return nil, fmt.Errorf("GitHub API error (status %d): %s", resp.StatusCode, readErrorBody(resp.Body))
154270
}
155271

156-
var orgs []GitHubUserOrOrg
157-
if err := json.NewDecoder(resp.Body).Decode(&orgs); err != nil {
158-
return nil, fmt.Errorf("failed to decode organizations response: %w", err)
272+
var memberships []githubOrgMembership
273+
if err := json.NewDecoder(resp.Body).Decode(&memberships); err != nil {
274+
return nil, fmt.Errorf("failed to decode organization memberships response: %w", err)
159275
}
160276

161-
return orgs, nil
277+
return memberships, nil
162278
}
163279

164280
// buildPermissions builds permissions based on GitHub user and their organizations
165281
func (h *GitHubHandler) buildPermissions(username string, orgs []GitHubUserOrOrg) []auth.Permission {
166-
permissions := []auth.Permission{}
167-
168-
// Assert user and org names match expected regex, to harden against people doing weird things in names
282+
// Assert the username matches the expected regex, to harden against people doing
283+
// weird things in names. The username is the caller's own identity, so if it is
284+
// invalid we grant nothing.
169285
if !isValidGitHubName(username) {
170286
return nil
171287
}
172-
for _, org := range orgs {
173-
if !isValidGitHubName(org.Login) {
174-
return nil
175-
}
176-
}
177288

178289
// Add permission for user's own namespace
179-
permissions = append(permissions, auth.Permission{
290+
permissions := []auth.Permission{{
180291
Action: auth.PermissionActionPublish,
181292
ResourcePattern: fmt.Sprintf("io.github.%s/*", username),
182-
})
293+
}}
183294

184-
// Add permissions for each organization
295+
// Add permissions for each organization the user administers (Owner role). Skip
296+
// any org whose name fails validation rather than rejecting the whole set, so one
297+
// unexpected org name can't strip the caller's personal namespace or their other
298+
// (valid) org grants.
185299
for _, org := range orgs {
300+
if !isValidGitHubName(org.Login) {
301+
continue
302+
}
186303
permissions = append(permissions, auth.Permission{
187304
Action: auth.PermissionActionPublish,
188305
ResourcePattern: fmt.Sprintf("io.github.%s/*", org.Login),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"testing"
7+
8+
"github.com/danielgtaylor/huma/v2"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// The 401 returned to the client must not echo the internal error, which can
14+
// include the raw upstream GitHub response body captured by readErrorBody
15+
// (CWE-209). huma serializes extra error args into the response body, so the fix
16+
// is to log server-side and return only a generic message. This mirrors the
17+
// regression test added for the GET /v0/servers 500 leak (#1338).
18+
func TestTokenExchangeError_doesNotLeakDetail(t *testing.T) {
19+
const internal = `failed to get GitHub organizations: GitHub API error (status 500): {"message":"internal-host db detail"}`
20+
21+
err := tokenExchangeError(errors.New(internal))
22+
require.Error(t, err)
23+
24+
var se huma.StatusError
25+
require.True(t, errors.As(err, &se))
26+
assert.Equal(t, 401, se.GetStatus())
27+
28+
body, marshalErr := json.Marshal(err)
29+
require.NoError(t, marshalErr)
30+
assert.NotContains(t, string(body), "internal-host db detail")
31+
assert.NotContains(t, string(body), "status 500")
32+
assert.Contains(t, string(body), "Token exchange failed")
33+
}

0 commit comments

Comments
 (0)