/ src / auth.go
auth.go
  1  package git_pages
  2  
  3  import (
  4  	"crypto/sha256"
  5  	"encoding/base64"
  6  	"errors"
  7  	"fmt"
  8  	"log"
  9  	"net"
 10  	"net/http"
 11  	"net/url"
 12  	"slices"
 13  	"strings"
 14  )
 15  
 16  type AuthError struct {
 17  	code  int
 18  	error string
 19  }
 20  
 21  func (e AuthError) Error() string {
 22  	return e.error
 23  }
 24  
 25  func IsUnauthorized(err error) bool {
 26  	var authErr AuthError
 27  	if errors.As(err, &authErr) {
 28  		return authErr.code == http.StatusUnauthorized
 29  	}
 30  	return false
 31  }
 32  
 33  func authorizeInsecure() *Authorization {
 34  	if config.Insecure { // for testing only
 35  		log.Println("auth: INSECURE mode")
 36  		return &Authorization{
 37  			repoURLs: nil,
 38  			branch:   "pages",
 39  		}
 40  	}
 41  	return nil
 42  }
 43  
 44  func GetHost(r *http.Request) (string, error) {
 45  	// FIXME: handle IDNA
 46  	host, _, err := net.SplitHostPort(r.Host)
 47  	if err != nil {
 48  		// dirty but the go stdlib doesn't have a "split port if present" function
 49  		host = r.Host
 50  	}
 51  	if strings.HasPrefix(host, ".") {
 52  		return "", AuthError{http.StatusBadRequest,
 53  			fmt.Sprintf("host name %q is reserved", host)}
 54  	}
 55  	return host, nil
 56  }
 57  
 58  func GetProjectName(r *http.Request) (string, error) {
 59  	// path must be either `/` or `/foo/` (`/foo` is accepted as an alias)
 60  	path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/")
 61  	if path == ".index" || strings.HasPrefix(path, ".index/") {
 62  		return "", AuthError{http.StatusBadRequest,
 63  			fmt.Sprintf("directory name %q is reserved", ".index")}
 64  	} else if strings.Contains(path, "/") {
 65  		return "", AuthError{http.StatusBadRequest,
 66  			"directories nested too deep"}
 67  	}
 68  
 69  	if path == "" {
 70  		// path `/` corresponds to pseudo-project `.index`
 71  		return ".index", nil
 72  	} else {
 73  		return path, nil
 74  	}
 75  }
 76  
 77  type Authorization struct {
 78  	// If `nil`, any URL is allowed. If not, only those in the set are allowed.
 79  	repoURLs []string
 80  	// Only the exact branch is allowed.
 81  	branch string
 82  }
 83  
 84  func authorizeDNSChallenge(r *http.Request) (*Authorization, error) {
 85  	host, err := GetHost(r)
 86  	if err != nil {
 87  		return nil, err
 88  	}
 89  
 90  	authorization := r.Header.Get("Authorization")
 91  	if authorization == "" {
 92  		return nil, AuthError{http.StatusUnauthorized,
 93  			"missing Authorization header"}
 94  	}
 95  
 96  	scheme, param, success := strings.Cut(authorization, " ")
 97  	if !success {
 98  		return nil, AuthError{http.StatusBadRequest,
 99  			"malformed Authorization header"}
100  	}
101  
102  	if scheme != "Pages" && scheme != "Basic" {
103  		return nil, AuthError{http.StatusBadRequest,
104  			"unknown Authorization scheme"}
105  	}
106  
107  	// services like GitHub and Gogs cannot send a custom Authorization: header, but supplying
108  	// username and password in the URL is basically just as good
109  	if scheme == "Basic" {
110  		basicParam, err := base64.StdEncoding.DecodeString(param)
111  		if err != nil {
112  			return nil, AuthError{http.StatusBadRequest,
113  				"malformed Authorization: Basic header"}
114  		}
115  
116  		username, password, found := strings.Cut(string(basicParam), ":")
117  		if !found {
118  			return nil, AuthError{http.StatusBadRequest,
119  				"malformed Authorization: Basic parameter"}
120  		}
121  
122  		if username != "Pages" {
123  			return nil, AuthError{http.StatusUnauthorized,
124  				"unexpected Authorization: Basic username"}
125  		}
126  
127  		param = password
128  	}
129  
130  	challengeHostname := fmt.Sprintf("_git-pages-challenge.%s", host)
131  	actualChallenges, err := net.LookupTXT(challengeHostname)
132  	if err != nil {
133  		return nil, AuthError{http.StatusUnauthorized,
134  			fmt.Sprintf("failed to look up DNS challenge: %s TXT", challengeHostname)}
135  	}
136  
137  	expectedChallenge := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s %s", host, param)))
138  	if !slices.Contains(actualChallenges, expectedChallenge) {
139  		return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf(
140  			"defeated by DNS challenge: %s TXT %v does not include %s",
141  			challengeHostname,
142  			actualChallenges,
143  			expectedChallenge,
144  		)}
145  	}
146  
147  	return &Authorization{
148  		repoURLs: nil, // any
149  		branch:   "pages",
150  	}, nil
151  }
152  
153  func authorizeDNSAllowlist(r *http.Request) (*Authorization, error) {
154  	host, err := GetHost(r)
155  	if err != nil {
156  		return nil, err
157  	}
158  
159  	allowlistHostname := fmt.Sprintf("_git-pages-repository.%s", host)
160  	records, err := net.LookupTXT(allowlistHostname)
161  	if err != nil {
162  		return nil, AuthError{http.StatusUnauthorized,
163  			fmt.Sprintf("failed to look up DNS repository allowlist: %s TXT", allowlistHostname)}
164  	}
165  
166  	var (
167  		repoURLs []string
168  		errs     []error
169  	)
170  	for _, record := range records {
171  		if parsedURL, err := url.Parse(record); err != nil {
172  			errs = append(errs, fmt.Errorf("failed to parse URL: %s TXT %q", allowlistHostname, record))
173  		} else if !parsedURL.IsAbs() {
174  			errs = append(errs, fmt.Errorf("repository URL is not absolute: %s TXT %q", allowlistHostname, record))
175  		} else {
176  			repoURLs = append(repoURLs, record)
177  		}
178  	}
179  
180  	if len(repoURLs) == 0 {
181  		if len(records) > 0 {
182  			errs = append([]error{AuthError{http.StatusUnauthorized,
183  				fmt.Sprintf("no valid DNS TXT records for %s", allowlistHostname)}},
184  				errs...)
185  			return nil, joinErrors(errs...)
186  		} else {
187  			return nil, AuthError{http.StatusUnauthorized,
188  				fmt.Sprintf("no DNS TXT records found for %s", allowlistHostname)}
189  		}
190  	}
191  
192  	return &Authorization{
193  		repoURLs: repoURLs,
194  		branch:   "pages",
195  	}, err
196  }
197  
198  // used for `/.git-pages/...` metadata
199  func authorizeWildcardMatchHost(r *http.Request, pattern *WildcardPattern) (*Authorization, error) {
200  	host, err := GetHost(r)
201  	if err != nil {
202  		return nil, err
203  	}
204  
205  	if _, found := pattern.Matches(host); found {
206  		return &Authorization{
207  			repoURLs: []string{},
208  			branch:   "",
209  		}, nil
210  	} else {
211  		return nil, AuthError{
212  			http.StatusUnauthorized,
213  			fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()),
214  		}
215  	}
216  }
217  
218  // used for updates to site content
219  func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Authorization, error) {
220  	host, err := GetHost(r)
221  	if err != nil {
222  		return nil, err
223  	}
224  
225  	projectName, err := GetProjectName(r)
226  	if err != nil {
227  		return nil, err
228  	}
229  
230  	if userName, found := pattern.Matches(host); found {
231  		var repoURLs []string
232  		repoURLTemplate := pattern.CloneURL
233  		if projectName == ".index" {
234  			for _, indexRepoTemplate := range pattern.IndexRepos {
235  				indexRepo := indexRepoTemplate.ExecuteString(map[string]any{"user": userName})
236  				repoURLs = append(repoURLs, repoURLTemplate.ExecuteString(map[string]any{
237  					"user":    userName,
238  					"project": indexRepo,
239  				}))
240  			}
241  		} else {
242  			repoURLs = append(repoURLs, repoURLTemplate.ExecuteString(map[string]any{
243  				"user":    userName,
244  				"project": projectName,
245  			}))
246  		}
247  		return &Authorization{
248  			repoURLs: repoURLs,
249  			branch:   "pages",
250  		}, nil
251  	} else {
252  		return nil, AuthError{
253  			http.StatusUnauthorized,
254  			fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()),
255  		}
256  	}
257  }
258  
259  // used for compatibility with Codeberg Pages v2
260  // see https://docs.codeberg.org/codeberg-pages/using-custom-domain/
261  func authorizeCodebergPagesV2(r *http.Request) (*Authorization, error) {
262  	host, err := GetHost(r)
263  	if err != nil {
264  		return nil, err
265  	}
266  
267  	dnsRecords := []string{}
268  
269  	cnameRecord, err := net.LookupCNAME(host)
270  	// "LookupCNAME does not return an error if host does not contain DNS "CNAME" records,
271  	// as long as host resolves to address records.
272  	if err == nil && cnameRecord != host {
273  		// LookupCNAME() returns a domain with the root label, i.e. `username.codeberg.page.`,
274  		// with the trailing dot
275  		dnsRecords = append(dnsRecords, strings.TrimSuffix(cnameRecord, "."))
276  	}
277  
278  	txtRecords, err := net.LookupTXT(host)
279  	if err == nil {
280  		dnsRecords = append(dnsRecords, txtRecords...)
281  	}
282  
283  	if len(dnsRecords) > 0 {
284  		log.Printf("auth: %s TXT/CNAME: %q\n", host, dnsRecords)
285  	}
286  
287  	for _, dnsRecord := range dnsRecords {
288  		domainParts := strings.Split(dnsRecord, ".")
289  		slices.Reverse(domainParts)
290  		if domainParts[0] == "" {
291  			domainParts = domainParts[1:]
292  		}
293  		if len(domainParts) >= 3 && len(domainParts) <= 5 {
294  			if domainParts[0] == "page" && domainParts[1] == "codeberg" {
295  				// map of domain names to allowed repository and branch:
296  				//  * {username}.codeberg.page =>
297  				//      https://codeberg.org/{username}/pages.git#main
298  				//  * {reponame}.{username}.codeberg.page =>
299  				//      https://codeberg.org/{username}/{reponame}.git#pages
300  				//  * {branch}.{reponame}.{username}.codeberg.page =>
301  				//      https://codeberg.org/{username}/{reponame}.git#{branch}
302  				username := domainParts[2]
303  				reponame := "pages"
304  				branch := "main"
305  				if len(domainParts) >= 4 {
306  					reponame = domainParts[3]
307  					branch = "pages"
308  				}
309  				if len(domainParts) == 5 {
310  					branch = domainParts[4]
311  				}
312  				return &Authorization{
313  					repoURLs: []string{
314  						fmt.Sprintf("https://codeberg.org/%s/%s.git", username, reponame),
315  					},
316  					branch: branch,
317  				}, nil
318  			}
319  		}
320  	}
321  
322  	return nil, AuthError{
323  		http.StatusUnauthorized,
324  		fmt.Sprintf("domain %s does not have Codeberg Pages TXT or CNAME records", host),
325  	}
326  }
327  
328  func AuthorizeMetadataRetrieval(r *http.Request) (*Authorization, error) {
329  	causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
330  
331  	auth := authorizeInsecure()
332  	if auth != nil {
333  		return auth, nil
334  	}
335  
336  	auth, err := authorizeDNSChallenge(r)
337  	if err != nil && IsUnauthorized(err) {
338  		causes = append(causes, err)
339  	} else if err != nil { // bad request
340  		return nil, err
341  	} else {
342  		log.Println("auth: DNS challenge")
343  		return auth, nil
344  	}
345  
346  	for _, pattern := range wildcardPatterns {
347  		auth, err = authorizeWildcardMatchHost(r, pattern)
348  		if err != nil && IsUnauthorized(err) {
349  			causes = append(causes, err)
350  		} else if err != nil { // bad request
351  			return nil, err
352  		} else {
353  			log.Printf("auth: wildcard %s\n", pattern.GetHost())
354  			return auth, nil
355  		}
356  	}
357  
358  	if config.Feature("codeberg-pages-compat") {
359  		auth, err = authorizeCodebergPagesV2(r)
360  		if err != nil && IsUnauthorized(err) {
361  			causes = append(causes, err)
362  		} else if err != nil { // bad request
363  			return nil, err
364  		} else {
365  			log.Printf("auth: codeberg %s\n", r.Host)
366  			return auth, nil
367  		}
368  	}
369  
370  	return nil, joinErrors(causes...)
371  }
372  
373  // Returns `repoURLs, err` where if `err == nil` then the request is authorized to clone from
374  // any repository URL included in `repoURLs` (by case-insensitive comparison), or any URL at all
375  // if `repoURLs == nil`.
376  func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) {
377  	causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
378  
379  	if err := CheckForbiddenDomain(r); err != nil {
380  		return nil, err
381  	}
382  
383  	auth := authorizeInsecure()
384  	if auth != nil {
385  		return auth, nil
386  	}
387  
388  	// DNS challenge gives absolute authority.
389  	auth, err := authorizeDNSChallenge(r)
390  	if err != nil && IsUnauthorized(err) {
391  		causes = append(causes, err)
392  	} else if err != nil { // bad request
393  		return nil, err
394  	} else {
395  		log.Println("auth: DNS challenge: allow *")
396  		return auth, nil
397  	}
398  
399  	// DNS allowlist gives authority to update but not delete.
400  	if r.Method == http.MethodPut || r.Method == http.MethodPost {
401  		auth, err = authorizeDNSAllowlist(r)
402  		if err != nil && IsUnauthorized(err) {
403  			causes = append(causes, err)
404  		} else if err != nil { // bad request
405  			return nil, err
406  		} else {
407  			log.Printf("auth: DNS allowlist: allow %v\n", auth.repoURLs)
408  			return auth, nil
409  		}
410  	}
411  
412  	// Wildcard match is only available for webhooks, not the REST API.
413  	if r.Method == http.MethodPost {
414  		for _, pattern := range wildcardPatterns {
415  			auth, err = authorizeWildcardMatchSite(r, pattern)
416  			if err != nil && IsUnauthorized(err) {
417  				causes = append(causes, err)
418  			} else if err != nil { // bad request
419  				return nil, err
420  			} else {
421  				log.Printf("auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs)
422  				return auth, nil
423  			}
424  		}
425  
426  		if config.Feature("codeberg-pages-compat") {
427  			auth, err = authorizeCodebergPagesV2(r)
428  			if err != nil && IsUnauthorized(err) {
429  				causes = append(causes, err)
430  			} else if err != nil { // bad request
431  				return nil, err
432  			} else {
433  				log.Printf("auth: codeberg %s: allow %v branch %s\n",
434  					r.Host, auth.repoURLs, auth.branch)
435  				return auth, nil
436  			}
437  		}
438  	}
439  
440  	return nil, joinErrors(causes...)
441  }
442  
443  func AuthorizeRepository(repoURL string, auth *Authorization) error {
444  	if auth.repoURLs == nil {
445  		return nil // any
446  	}
447  
448  	repoURL = strings.ToLower(repoURL)
449  
450  	if config.Limits.AllowedRepositoryURLPrefixes != nil {
451  		allowedPrefix := false
452  		for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes {
453  			if strings.HasPrefix(repoURL, strings.ToLower(allowedRepoURLPrefix)) {
454  				allowedPrefix = true
455  				break
456  			}
457  		}
458  		if !allowedPrefix {
459  			return AuthError{
460  				http.StatusUnauthorized,
461  				fmt.Sprintf("clone URL not in prefix allowlist %v",
462  					config.Limits.AllowedRepositoryURLPrefixes),
463  			}
464  		}
465  	}
466  
467  	allowed := false
468  	for _, allowedRepoURL := range auth.repoURLs {
469  		if repoURL == strings.ToLower(allowedRepoURL) {
470  			allowed = true
471  			break
472  		}
473  	}
474  	if !allowed {
475  		return AuthError{
476  			http.StatusUnauthorized,
477  			fmt.Sprintf("clone URL not in allowlist %v", auth.repoURLs),
478  		}
479  	}
480  
481  	return nil
482  }
483  
484  // The purpose of `allowRepoURLs` is to make sure that only authorized content is deployed
485  // to the site despite the fact that the non-shared-secret authorization methods allow anyone
486  // to impersonate the legitimate webhook sender. (If switching to another repository URL would
487  // be catastrophic, then so would be switching to a different branch.)
488  func AuthorizeBranch(branch string, auth *Authorization) error {
489  	if auth.repoURLs == nil {
490  		return nil // any
491  	}
492  
493  	if branch == auth.branch {
494  		return nil
495  	} else {
496  		return AuthError{
497  			http.StatusUnauthorized,
498  			fmt.Sprintf("branch %s not in allowlist %v", branch, []string{auth.branch}),
499  		}
500  	}
501  }
502  
503  func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
504  	causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
505  
506  	if err := CheckForbiddenDomain(r); err != nil {
507  		return nil, err
508  	}
509  
510  	auth := authorizeInsecure()
511  	if auth != nil {
512  		return auth, nil
513  	}
514  
515  	if config.Limits.AllowedRepositoryURLPrefixes != nil {
516  		return nil, AuthError{http.StatusUnauthorized, "updating from archive not allowed"}
517  	}
518  
519  	// DNS challenge gives absolute authority.
520  	auth, err := authorizeDNSChallenge(r)
521  	if err != nil && IsUnauthorized(err) {
522  		causes = append(causes, err)
523  	} else if err != nil { // bad request
524  		return nil, err
525  	} else {
526  		log.Println("auth: DNS challenge")
527  		return auth, nil
528  	}
529  
530  	return nil, joinErrors(causes...)
531  }
532  
533  func CheckForbiddenDomain(r *http.Request) error {
534  	host, err := GetHost(r)
535  	if err != nil {
536  		return err
537  	}
538  
539  	host = strings.ToLower(host)
540  	for _, reservedDomain := range config.Limits.ForbiddenDomains {
541  		reservedDomain = strings.ToLower(reservedDomain)
542  		if host == reservedDomain || strings.HasSuffix(host, fmt.Sprintf(".%s", reservedDomain)) {
543  			return AuthError{http.StatusForbidden, "forbidden domain"}
544  		}
545  	}
546  
547  	return nil
548  }