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>
This commit is contained in:
Sash 2026-08-07 05:38:34 +07:00 • committed by GitHub
commit 5c19656cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 0 deletions

View file

@ -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 <name>.github.io from the <name>/<name>.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.<org>/* 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

View file

@ -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)
}
})
}
}