Skip to content

Commit 5c19656

Browse files
fix(auth): reject github.io domains in DNS/HTTP token exchange (org namespace takeover) (#1506)
## Summary - The GitHub token method grants `io.github.<org>/*` 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. - `<org>.github.io` is served by GitHub Pages from the `<org>/<org>.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.<org>/*` 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 `<org>`'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 <sash@ela.city> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0f78916 commit 5c19656

2 files changed

Lines changed: 49 additions & 0 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ func ValidateDomainAndTimestamp(domain, timestamp string) (*time.Time, error) {
8989
return nil, fmt.Errorf("invalid domain format")
9090
}
9191

92+
if isGitHubPagesDomain(domain) {
93+
return nil, fmt.Errorf(
94+
"github.io domains cannot be used with DNS/HTTP authentication; " +
95+
"use GitHub authentication for io.github.* namespaces")
96+
}
97+
9298
ts, err := time.Parse(time.RFC3339, timestamp)
9399
if err != nil {
94100
return nil, fmt.Errorf("invalid timestamp format: %w", err)
@@ -382,6 +388,18 @@ func ReverseString(domain string) string {
382388
return strings.Join(parts, ".")
383389
}
384390

391+
// isGitHubPagesDomain reports whether domain is github.io or a subdomain of it.
392+
// GitHub Pages serves <name>.github.io from the <name>/<name>.github.io repository,
393+
// so the HTTP proof only demonstrates push access to that one repository. For an
394+
// organization that is a far weaker bar than the org-Owner ("admin") check the
395+
// GitHub authentication method enforces for io.github.<org>/* namespaces — accepting
396+
// the proof here would let any member with write access to the Pages repo mint the
397+
// whole org namespace. Users of io.github.* namespaces authenticate via GitHub.
398+
func isGitHubPagesDomain(domain string) bool {
399+
d := strings.ToLower(domain)
400+
return d == "github.io" || strings.HasSuffix(d, ".github.io")
401+
}
402+
385403
func IsValidDomain(domain string) bool {
386404
if len(domain) == 0 || len(domain) > 253 {
387405
return false

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth_test
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth"
78
)
@@ -47,3 +48,33 @@ func TestIsValidDomain(t *testing.T) {
4748
})
4849
}
4950
}
51+
52+
func TestValidateDomainAndTimestampRejectsGitHubPages(t *testing.T) {
53+
timestamp := time.Now().UTC().Format(time.RFC3339)
54+
tests := []struct {
55+
domain string
56+
wantError bool
57+
}{
58+
// GitHub Pages domains must not mint io.github.* namespaces via DNS/HTTP
59+
{"my-org.github.io", true},
60+
{"my-org.GitHub.IO", true},
61+
{"github.io", true},
62+
{"sub.my-org.github.io", true},
63+
64+
// Lookalikes and ordinary domains stay allowed
65+
{"github.io.evil-example.com", false},
66+
{"example.com", false},
67+
{"my-org.github.io.example.com", false},
68+
}
69+
for _, tc := range tests {
70+
t.Run(tc.domain, func(t *testing.T) {
71+
_, err := auth.ValidateDomainAndTimestamp(tc.domain, timestamp)
72+
if tc.wantError && err == nil {
73+
t.Errorf("ValidateDomainAndTimestamp(%q) succeeded, want github.io rejection", tc.domain)
74+
}
75+
if !tc.wantError && err != nil {
76+
t.Errorf("ValidateDomainAndTimestamp(%q) failed: %v", tc.domain, err)
77+
}
78+
})
79+
}
80+
}

0 commit comments

Comments
 (0)