registry/internal/api/handlers/v0/auth/dns.go
Radoslav Dimitrov 8759d9d8c0
ci: bump golangci-lint to v2.13.1 to fix panic on Go 1.27 (#1561)
CI is red on `main` and on every open PR (including the Dependabot PRs
#1531, #1552–#1554) with:

```
panic: file requires newer Go version go1.27 (application built with go1.26)
golangci-lint exit with code 2
```

## Cause

`actions/setup-go` with `go-version: 'stable'` started resolving to
**go1.27.0**. The pinned golangci-lint **v2.11.4** release binary is
built with **go1.26**, and its `go/types` refuses to typecheck source
files declaring a newer language version. Nothing in the failing PRs is
at fault.

## Fix

Bump to **golangci-lint v2.13.1**, whose release binaries are built with
go1.27, so linting works under `stable` again.

That bump also pulls in newer linter versions, which surface 57 findings
this tree did not have before. Handled as follows:

| Linter | Count | Resolution |
|---|---|---|
| `goconst` | 50 | 39 were repeated literals in `_test.go` files —
goconst started counting those in v2.12.0 (golangci-lint#6480 exposed
`ignore-tests`). Fixture data like `"1.0.0"` (89 occurrences) is not a
missing constant, so `goconst.ignore-tests: true`. The other 11 were
real duplication in production code (OpenAPI tags, the `bearer` security
scheme name, `$ref`, `http`/`https`) and became named constants. |
| `gosec` G710 | 1 | **True positive, fixed.** See below. |
| `staticcheck` SA1019 | 6 | Go 1.26 deprecated `ecdsa.PublicKey.X/Y`
and `PrivateKey.D`. Annotated with the same targeted `nolint` +
rationale already used for `ScalarBaseMult` in
`cmd/publisher/auth/common.go`. |

### The open redirect is real

`TrailingSlashMiddleware` copied the entire `*url.URL` before rewriting
the path. For an absolute-form request URI — legal for a Go HTTP server
to parse, e.g. `GET http://evil.com/foo/ HTTP/1.1` — `r.URL.Scheme` and
`r.URL.Host` are populated, and they were echoed straight back in the
`Location` header. The redirect target is now built from the cleaned
path and query alone. The existing `path.Clean` hardening for
GHSA-v8vw-gw5j-w7m6 (protocol-relative `//evil.com/`) is unchanged.

### Also

Switched `gomodguard` → `gomodguard_v2`; the former is deprecated as of
v2.12.0 and was emitting a warning on every run.

## Verification

- `golangci-lint v2.13.1` against this tree: **0 issues**, no
deprecation warnings
- `make validate`: all validations passed
- `go build ./...` and tests for every touched package pass (DB-backed
tests need Postgres; left to CI)

## Note on migrating the ecdsa key paths

Moving those six sites to `PublicKey.Bytes` /
`ParseUncompressedPublicKey` / `crypto/ecdh` touches key parsing and
signature verification. It should be its own reviewed PR rather than
riding along on a CI unblock. Happy to open one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:14:13 +03:00

157 lines
5.3 KiB
Go

package auth
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
)
// DNSTokenExchangeInput represents the input for DNS-based authentication
type DNSTokenExchangeInput struct {
Body SignatureTokenExchangeInput
}
// DNSResolver defines the interface for DNS resolution
type DNSResolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
}
// DefaultDNSResolver uses Go's standard DNS resolution
type DefaultDNSResolver struct{}
// LookupTXT performs DNS TXT record lookup
func (r *DefaultDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
return (&net.Resolver{}).LookupTXT(ctx, name)
}
// DNSAuthHandler handles DNS-based authentication
type DNSAuthHandler struct {
CoreAuthHandler
resolver DNSResolver
}
// NewDNSAuthHandler creates a new DNS authentication handler
func NewDNSAuthHandler(cfg *config.Config) *DNSAuthHandler {
return &DNSAuthHandler{
CoreAuthHandler: *NewCoreAuthHandler(cfg),
resolver: &DefaultDNSResolver{},
}
}
// SetResolver sets a custom DNS resolver (used for testing)
func (h *DNSAuthHandler) SetResolver(resolver DNSResolver) {
h.resolver = resolver
}
// RegisterDNSEndpoint registers the DNS authentication endpoint
func RegisterDNSEndpoint(api huma.API, pathPrefix string, cfg *config.Config) {
handler := NewDNSAuthHandler(cfg)
// DNS authentication endpoint
huma.Register(api, huma.Operation{
OperationID: "exchange-dns-token" + strings.ReplaceAll(pathPrefix, "/", "-"),
Method: http.MethodPost,
Path: pathPrefix + "/auth/dns",
Summary: "Exchange DNS signature for Registry JWT",
Description: "Authenticate using DNS TXT record public key and signed timestamp",
Tags: []string{tagAuth},
}, func(ctx context.Context, input *DNSTokenExchangeInput) (*v0.Response[auth.TokenResponse], error) {
response, err := handler.ExchangeToken(ctx, input.Body.Domain, input.Body.Timestamp, input.Body.SignedTimestamp)
if err != nil {
return nil, huma.Error401Unauthorized("DNS authentication failed", err)
}
return &v0.Response[auth.TokenResponse]{
Body: *response,
}, nil
})
}
// commonWrongSelectors lists subdomain prefixes that users frequently mistake for the
// MCP DNS auth record location (DKIM-style intuition). MCP DNS auth uses the apex,
// like SPF — see #385, #1103, #1126 for the recurring confusion.
var commonWrongSelectors = []string{"_mcp-auth", "_mcp-registry"}
// ExchangeToken exchanges DNS signature for a Registry JWT token
func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, signedTimestamp string) (*auth.TokenResponse, error) {
keyFetcher := func(ctx context.Context, domain string) ([]string, error) {
// Apply a timeout to DNS lookup to prevent resource exhaustion from slow/malicious DNS servers
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Lookup DNS TXT records
// DNS implies a hierarchy where subdomains are treated as part of the parent domain,
// therefore we grant permissions for all subdomains (e.g., com.example.*)
// This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
txtRecords, err := h.resolver.LookupTXT(timeoutCtx, domain)
if err != nil {
return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err)
}
if !hasMCPRecord(txtRecords) {
if found := h.findMisplacedSelector(timeoutCtx, domain); found != "" {
return nil, fmt.Errorf(
"no MCPv1 TXT record at %q, but one was found at %q — "+
"MCP DNS auth requires the record at the apex domain, not under a selector",
domain, found,
)
}
}
return txtRecords, nil
}
allowSubdomains := true
return h.CoreAuthHandler.ExchangeToken(ctx, domain, timestamp, signedTimestamp, keyFetcher, allowSubdomains, auth.MethodDNS)
}
// hasMCPRecord reports whether any of the supplied TXT records contains a well-formed
// MCPv1 proof record. Uses the same strict pattern as the parser so a malformed
// "v=MCPv1" string at the apex doesn't suppress the misplaced-selector probe.
func hasMCPRecord(records []string) bool {
for _, r := range records {
if MCPProofRecordPattern.MatchString(r) {
return true
}
}
return false
}
// findMisplacedSelector probes a small fixed set of common wrong selectors and returns the
// first one that holds an MCPv1 record, or "" if none do. Lookups run in parallel with a
// short individual timeout so a slow/missing zone never delays the response by much.
func (h *DNSAuthHandler) findMisplacedSelector(ctx context.Context, domain string) string {
type result struct {
name string
found bool
}
results := make(chan result, len(commonWrongSelectors))
for _, selector := range commonWrongSelectors {
name := selector + "." + domain
go func(name string) {
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
records, err := h.resolver.LookupTXT(lookupCtx, name)
if err != nil {
results <- result{name: name, found: false}
return
}
results <- result{name: name, found: hasMCPRecord(records)}
}(name)
}
for range commonWrongSelectors {
r := <-results
if r.found {
return r.name
}
}
return ""
}