Skip to content

Commit 8efede8

Browse files
committed
feat: add server name scoping support in DNS TXT records (#481)
Implements optional n=<pattern> parameter in DNS TXT records to allow fine-grained permission scoping for server names. This enables DNS controllers to delegate limited namespaces to different teams while maintaining security isolation. Key changes: - Added DNSAuthRecord struct to support name patterns alongside public keys - Collect all valid DNS records instead of stopping at first match - Validate name patterns must start with reverse domain to prevent cross-domain privilege escalation - Add delimiter checking after domain prefix (must be . or /) - Enforce single-slash rule for server names per PR #476 - Added comprehensive tests for all scoping scenarios Example usage: v=MCPv1; k=ed25519; p=<key>; n=com.example/team-foo-* This change addresses Microsoft's requirement for granular permission control within path portions while preserving existing behavior for records without the n= parameter. Fixes #481
1 parent 2280e4d commit 8efede8

File tree

2 files changed

+325
-30
lines changed

2 files changed

+325
-30
lines changed

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

Lines changed: 93 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ func (r *DefaultDNSResolver) LookupTXT(ctx context.Context, name string) ([]stri
4040
return (&net.Resolver{}).LookupTXT(ctx, name)
4141
}
4242

43+
// DNSAuthRecord represents a DNS TXT authentication record with optional name pattern
44+
type DNSAuthRecord struct {
45+
PublicKey ed25519.PublicKey
46+
NamePattern string // Defaults to "*" for wildcard access
47+
}
48+
4349
// DNSAuthHandler handles DNS-based authentication
4450
type DNSAuthHandler struct {
4551
config *config.Config
@@ -120,29 +126,34 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s
120126
return nil, fmt.Errorf("failed to lookup DNS TXT records: %w", err)
121127
}
122128

123-
// Parse public keys from TXT records
124-
publicKeys := h.parsePublicKeysFromTXT(txtRecords)
129+
// Parse auth records from TXT records
130+
authRecords := h.parseAuthRecordsFromTXT(txtRecords)
125131

126-
if len(publicKeys) == 0 {
132+
if len(authRecords) == 0 {
127133
return nil, fmt.Errorf("no valid MCP public keys found in DNS TXT records")
128134
}
129135

130-
// Verify signature with any of the public keys
136+
// Verify signature and collect all valid records
131137
messageBytes := []byte(timestamp)
132-
signatureValid := false
133-
for _, publicKey := range publicKeys {
134-
if ed25519.Verify(publicKey, messageBytes, signature) {
135-
signatureValid = true
136-
break
138+
var validRecords []DNSAuthRecord
139+
for _, record := range authRecords {
140+
if ed25519.Verify(record.PublicKey, messageBytes, signature) {
141+
validRecords = append(validRecords, record)
137142
}
138143
}
139144

140-
if !signatureValid {
145+
if len(validRecords) == 0 {
141146
return nil, fmt.Errorf("signature verification failed")
142147
}
143148

144-
// Build permissions for domain and subdomains
145-
permissions := h.buildPermissions(domain)
149+
// Build permissions from all valid records
150+
var permissions []auth.Permission
151+
for _, record := range validRecords {
152+
permissions = append(permissions, h.buildPermissions(domain, record.NamePattern)...)
153+
}
154+
if len(permissions) == 0 {
155+
return nil, fmt.Errorf("no valid permissions found for the given name pattern")
156+
}
146157

147158
// Create JWT claims
148159
jwtClaims := auth.JWTClaims{
@@ -160,14 +171,16 @@ func (h *DNSAuthHandler) ExchangeToken(ctx context.Context, domain, timestamp, s
160171
return tokenResponse, nil
161172
}
162173

163-
// parsePublicKeysFromTXT parses Ed25519 public keys from DNS TXT records
164-
func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.PublicKey {
165-
var publicKeys []ed25519.PublicKey
166-
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)`)
174+
// parseAuthRecordsFromTXT parses DNS authentication records from TXT records
175+
// Supports optional n=<pattern> parameter for name scoping
176+
func (h *DNSAuthHandler) parseAuthRecordsFromTXT(txtRecords []string) []DNSAuthRecord {
177+
var authRecords []DNSAuthRecord
178+
// Updated pattern to capture optional n=<pattern> parameter
179+
mcpPattern := regexp.MustCompile(`v=MCPv1;\s*k=ed25519;\s*p=([A-Za-z0-9+/=]+)(?:;\s*n=([^;]+))?`)
167180

168181
for _, record := range txtRecords {
169182
matches := mcpPattern.FindStringSubmatch(record)
170-
if len(matches) == 2 {
183+
if len(matches) >= 2 {
171184
// Decode base64 public key
172185
publicKeyBytes, err := base64.StdEncoding.DecodeString(matches[1])
173186
if err != nil {
@@ -178,29 +191,79 @@ func (h *DNSAuthHandler) parsePublicKeysFromTXT(txtRecords []string) []ed25519.P
178191
continue // Skip invalid key sizes
179192
}
180193

181-
publicKeys = append(publicKeys, ed25519.PublicKey(publicKeyBytes))
194+
// Extract name pattern or default to wildcard
195+
namePattern := "*"
196+
if len(matches) > 2 && matches[2] != "" {
197+
namePattern = strings.TrimSpace(matches[2])
198+
}
199+
200+
authRecords = append(authRecords, DNSAuthRecord{
201+
PublicKey: ed25519.PublicKey(publicKeyBytes),
202+
NamePattern: namePattern,
203+
})
182204
}
183205
}
184206

185-
return publicKeys
207+
return authRecords
186208
}
187209

188-
// buildPermissions builds permissions for a domain and its subdomains using reverse DNS notation
189-
func (h *DNSAuthHandler) buildPermissions(domain string) []auth.Permission {
210+
// buildPermissions builds permissions based on domain and name pattern
211+
// namePattern defaults to "*" for wildcard access (backward compatible)
212+
func (h *DNSAuthHandler) buildPermissions(domain string, namePattern string) []auth.Permission {
190213
reverseDomain := reverseString(domain)
191214

215+
// If namePattern is "*", grant traditional wildcard permissions
216+
if namePattern == "*" {
217+
permissions := []auth.Permission{
218+
// Grant permissions for the exact domain (e.g., com.example/*)
219+
{
220+
Action: auth.PermissionActionPublish,
221+
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
222+
},
223+
// DNS implies a hierarchy where subdomains are treated as part of the parent domain,
224+
// therefore we grant permissions for all subdomains (e.g., com.example.*)
225+
// This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
226+
{
227+
Action: auth.PermissionActionPublish,
228+
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
229+
},
230+
}
231+
return permissions
232+
}
233+
234+
// For specific name patterns, grant permission only for the specified pattern
235+
// This allows DNS controllers to scope permissions to specific prefixes
236+
// The name pattern MUST be scoped to the domain it is on.
237+
// We need to ensure proper delimiter checking to prevent prefix attacks
238+
// e.g., micro.com should not be able to claim com.microsoft/*
239+
if !strings.HasPrefix(namePattern, reverseDomain) {
240+
return []auth.Permission{}
241+
}
242+
243+
// Check that after the reverse domain, there's either:
244+
// - nothing (exact match)
245+
// - a '.' (subdomain like com.example.api)
246+
// - a '/' (path like com.example/foo)
247+
if len(namePattern) > len(reverseDomain) {
248+
delimiter := namePattern[len(reverseDomain)]
249+
if delimiter != '.' && delimiter != '/' {
250+
// Invalid pattern - doesn't have proper delimiter after domain
251+
return []auth.Permission{}
252+
}
253+
}
254+
255+
// Validate server name format: should have exactly one slash
256+
// This aligns with PR #476 requirements
257+
slashCount := strings.Count(namePattern, "/")
258+
if slashCount > 1 {
259+
// Invalid pattern - multiple slashes not allowed in server names
260+
return []auth.Permission{}
261+
}
262+
192263
permissions := []auth.Permission{
193-
// Grant permissions for the exact domain (e.g., com.example/*)
194-
{
195-
Action: auth.PermissionActionPublish,
196-
ResourcePattern: fmt.Sprintf("%s/*", reverseDomain),
197-
},
198-
// DNS implies a hierarchy where subdomains are treated as part of the parent domain,
199-
// therefore we grant permissions for all subdomains (e.g., com.example.*)
200-
// This is in line with other DNS-based authentication methods e.g. ACME DNS-01 challenges
201264
{
202265
Action: auth.PermissionActionPublish,
203-
ResourcePattern: fmt.Sprintf("%s.*", reverseDomain),
266+
ResourcePattern: namePattern,
204267
},
205268
}
206269

0 commit comments

Comments
 (0)