diff --git a/internal/api/handlers/v0/auth/common.go b/internal/api/handlers/v0/auth/common.go index 5d91047d..4c79bfc6 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 7662116a..35706464 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) + } + }) + } +}