From 5c19656cb97ff40d72a622b3bb768050d3a2a045 Mon Sep 17 00:00:00 2001 From: Sash Date: Fri, 7 Aug 2026 05:38:34 +0700 Subject: [PATCH] fix(auth): reject github.io domains in DNS/HTTP token exchange (org namespace takeover) (#1506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The GitHub token method grants `io.github./*` publish rights **only to org Owners** (active `admin` membership — `github_at.go`). - The DNS/HTTP token methods grant `reversed(domain)/*` for any proven domain, with no `github.io` special-casing and an empty `BlockedNamespaces` list. - `.github.io` is served by GitHub Pages from the `/.github.io` repository. **Push access to that one repository** — routinely held by ordinary org members, a much weaker bar than Owner — is enough to serve a key at `/.well-known/mcp-registry-auth`, exchange it for a JWT, and publish under the org's entire `io.github./*` namespace. - Reject `github.io` (and subdomains) at the shared `ValidateDomainAndTimestamp` seam used by both DNS and HTTP exchange; `io.github.*` publishers already have the GitHub method. ## Impact if unsolved Any org member with write access to the org's GitHub Pages repo (or an attacker compromising such a member) can mint MCP packages under the org's trusted namespace — supply-chain poisoning of every downstream user who installs ``'s servers from the registry, bypassing the deliberate Owner-only gate. ## Test plan - New `TestValidateDomainAndTimestampRejectsGitHubPages`: rejects `my-org.github.io` (incl. mixed case and subdomains), still allows lookalikes (`github.io.evil-example.com`, `my-org.github.io.example.com`) and ordinary domains. - Full `internal/api/handlers/v0/auth` package passes. Made with [Cursor](https://cursor.com) Signed-off-by: SashaMIT Co-authored-by: Cursor --- internal/api/handlers/v0/auth/common.go | 18 ++++++++++++ internal/api/handlers/v0/auth/common_test.go | 31 ++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/internal/api/handlers/v0/auth/common.go b/internal/api/handlers/v0/auth/common.go index 5d91047..4c79bfc 100644 --- a/internal/api/handlers/v0/auth/common.go +++ b/internal/api/handlers/v0/auth/common.go @@ -89,6 +89,12 @@ func ValidateDomainAndTimestamp(domain, timestamp string) (*time.Time, error) { return nil, fmt.Errorf("invalid domain format") } + if isGitHubPagesDomain(domain) { + return nil, fmt.Errorf( + "github.io domains cannot be used with DNS/HTTP authentication; " + + "use GitHub authentication for io.github.* namespaces") + } + ts, err := time.Parse(time.RFC3339, timestamp) if err != nil { return nil, fmt.Errorf("invalid timestamp format: %w", err) @@ -382,6 +388,18 @@ func ReverseString(domain string) string { return strings.Join(parts, ".") } +// isGitHubPagesDomain reports whether domain is github.io or a subdomain of it. +// GitHub Pages serves .github.io from the /.github.io repository, +// so the HTTP proof only demonstrates push access to that one repository. For an +// organization that is a far weaker bar than the org-Owner ("admin") check the +// GitHub authentication method enforces for io.github./* namespaces — accepting +// the proof here would let any member with write access to the Pages repo mint the +// whole org namespace. Users of io.github.* namespaces authenticate via GitHub. +func isGitHubPagesDomain(domain string) bool { + d := strings.ToLower(domain) + return d == "github.io" || strings.HasSuffix(d, ".github.io") +} + func IsValidDomain(domain string) bool { if len(domain) == 0 || len(domain) > 253 { return false diff --git a/internal/api/handlers/v0/auth/common_test.go b/internal/api/handlers/v0/auth/common_test.go index 7662116..3570646 100644 --- a/internal/api/handlers/v0/auth/common_test.go +++ b/internal/api/handlers/v0/auth/common_test.go @@ -2,6 +2,7 @@ package auth_test import ( "testing" + "time" "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" ) @@ -47,3 +48,33 @@ func TestIsValidDomain(t *testing.T) { }) } } + +func TestValidateDomainAndTimestampRejectsGitHubPages(t *testing.T) { + timestamp := time.Now().UTC().Format(time.RFC3339) + tests := []struct { + domain string + wantError bool + }{ + // GitHub Pages domains must not mint io.github.* namespaces via DNS/HTTP + {"my-org.github.io", true}, + {"my-org.GitHub.IO", true}, + {"github.io", true}, + {"sub.my-org.github.io", true}, + + // Lookalikes and ordinary domains stay allowed + {"github.io.evil-example.com", false}, + {"example.com", false}, + {"my-org.github.io.example.com", false}, + } + for _, tc := range tests { + t.Run(tc.domain, func(t *testing.T) { + _, err := auth.ValidateDomainAndTimestamp(tc.domain, timestamp) + if tc.wantError && err == nil { + t.Errorf("ValidateDomainAndTimestamp(%q) succeeded, want github.io rejection", tc.domain) + } + if !tc.wantError && err != nil { + t.Errorf("ValidateDomainAndTimestamp(%q) failed: %v", tc.domain, err) + } + }) + } +}