[mirror] Scalable static site server for Git forges (like GitHub Pages)
at main 815 lines 22 kB view raw
1package git_pages 2 3import ( 4 "crypto/sha256" 5 "encoding/base64" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "net" 10 "net/http" 11 "net/url" 12 "slices" 13 "strings" 14 "time" 15 16 "golang.org/x/net/idna" 17) 18 19type AuthError struct { 20 code int 21 error string 22} 23 24func (e AuthError) Error() string { 25 return e.error 26} 27 28func IsUnauthorized(err error) bool { 29 var authErr AuthError 30 if errors.As(err, &authErr) { 31 return authErr.code == http.StatusUnauthorized 32 } 33 return false 34} 35 36func authorizeInsecure(r *http.Request) *Authorization { 37 if config.Insecure { // for testing only 38 logc.Println(r.Context(), "auth: INSECURE mode") 39 return &Authorization{ 40 repoURLs: nil, 41 branch: "pages", 42 } 43 } 44 return nil 45} 46 47var idnaProfile = idna.New(idna.MapForLookup(), idna.BidiRule()) 48 49func GetHost(r *http.Request) (string, error) { 50 host, _, err := net.SplitHostPort(r.Host) 51 if err != nil { 52 host = r.Host 53 } 54 // this also rejects invalid characters and labels 55 host, err = idnaProfile.ToASCII(host) 56 if err != nil { 57 if config.Feature("relaxed-idna") { 58 // unfortunately, the go IDNA library has some significant issues around its 59 // Unicode TR46 implementation: https://github.com/golang/go/issues/76804 60 // we would like to allow *just* the _ here, but adding `idna.StrictDomainName(false)` 61 // would also accept domains like `*.foo.bar` which should clearly be disallowed. 62 // as a workaround, accept a domain name if it is valid with all `_` characters 63 // replaced with an alphanumeric character (we use `a`); this allows e.g. `foo_bar.xxx` 64 // and `foo__bar.xxx`, as well as `_foo.xxx` and `foo_.xxx`. labels starting with 65 // an underscore are explicitly rejected below. 66 _, err = idnaProfile.ToASCII(strings.ReplaceAll(host, "_", "a")) 67 } 68 if err != nil { 69 return "", AuthError{http.StatusBadRequest, 70 fmt.Sprintf("malformed host name %q", host)} 71 } 72 } 73 if strings.HasPrefix(host, ".") || strings.HasPrefix(host, "_") { 74 return "", AuthError{http.StatusBadRequest, 75 fmt.Sprintf("reserved host name %q", host)} 76 } 77 host = strings.TrimSuffix(host, ".") 78 return host, nil 79} 80 81func IsValidProjectName(name string) bool { 82 return !strings.HasPrefix(name, ".") && !strings.Contains(name, "%") 83} 84 85func GetProjectName(r *http.Request) (string, error) { 86 // path must be either `/` or `/foo/` (`/foo` is accepted as an alias) 87 path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/") 88 if !IsValidProjectName(path) { 89 return "", AuthError{http.StatusBadRequest, 90 fmt.Sprintf("directory name %q is reserved", ".index")} 91 } else if strings.Contains(path, "/") { 92 return "", AuthError{http.StatusBadRequest, 93 "directories nested too deep"} 94 } 95 96 if path == "" { 97 // path `/` corresponds to pseudo-project `.index` 98 return ".index", nil 99 } else { 100 return path, nil 101 } 102} 103 104type Authorization struct { 105 // If `nil`, any URL is allowed. If not, only those in the set are allowed. 106 repoURLs []string 107 // Only the exact branch is allowed. 108 branch string 109 // The authorized forge user. 110 forgeUser *ForgeUser 111} 112 113func authorizeDNSChallenge(r *http.Request) (*Authorization, error) { 114 host, err := GetHost(r) 115 if err != nil { 116 return nil, err 117 } 118 119 authorization := r.Header.Get("Authorization") 120 if authorization == "" { 121 return nil, AuthError{http.StatusUnauthorized, 122 "missing Authorization header"} 123 } 124 125 scheme, param, success := strings.Cut(authorization, " ") 126 if !success { 127 return nil, AuthError{http.StatusBadRequest, 128 "malformed Authorization header"} 129 } 130 131 if scheme != "Pages" && scheme != "Basic" { 132 return nil, AuthError{http.StatusBadRequest, 133 "unknown Authorization scheme"} 134 } 135 136 // services like GitHub and Gogs cannot send a custom Authorization: header, but supplying 137 // username and password in the URL is basically just as good 138 if scheme == "Basic" { 139 basicParam, err := base64.StdEncoding.DecodeString(param) 140 if err != nil { 141 return nil, AuthError{http.StatusBadRequest, 142 "malformed Authorization: Basic header"} 143 } 144 145 username, password, found := strings.Cut(string(basicParam), ":") 146 if !found { 147 return nil, AuthError{http.StatusBadRequest, 148 "malformed Authorization: Basic parameter"} 149 } 150 151 if username != "Pages" { 152 return nil, AuthError{http.StatusUnauthorized, 153 "unexpected Authorization: Basic username"} 154 } 155 156 param = password 157 } 158 159 challengeHostname := fmt.Sprintf("_git-pages-challenge.%s", host) 160 actualChallenges, err := net.LookupTXT(challengeHostname) 161 if err != nil { 162 return nil, AuthError{http.StatusUnauthorized, 163 fmt.Sprintf("failed to look up DNS challenge: %s TXT", challengeHostname)} 164 } 165 166 expectedChallenge := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s %s", host, param))) 167 if !slices.Contains(actualChallenges, expectedChallenge) { 168 return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf( 169 "defeated by DNS challenge: %s TXT %v does not include %s", 170 challengeHostname, 171 actualChallenges, 172 expectedChallenge, 173 )} 174 } 175 176 return &Authorization{ 177 repoURLs: nil, // any 178 branch: "pages", 179 }, nil 180} 181 182func authorizeDNSAllowlist(r *http.Request) (*Authorization, error) { 183 host, err := GetHost(r) 184 if err != nil { 185 return nil, err 186 } 187 188 projectName, err := GetProjectName(r) 189 if err != nil { 190 return nil, err 191 } 192 193 allowlistHostname := fmt.Sprintf("_git-pages-repository.%s", host) 194 records, err := net.LookupTXT(allowlistHostname) 195 if err != nil { 196 return nil, AuthError{http.StatusUnauthorized, 197 fmt.Sprintf("failed to look up DNS repository allowlist: %s TXT", allowlistHostname)} 198 } 199 200 if projectName != ".index" { 201 return nil, AuthError{http.StatusUnauthorized, 202 "DNS repository allowlist only authorizes index site"} 203 } 204 205 var ( 206 repoURLs []string 207 errs []error 208 ) 209 for _, record := range records { 210 if parsedURL, err := url.Parse(record); err != nil { 211 errs = append(errs, fmt.Errorf("failed to parse URL: %s TXT %q", allowlistHostname, record)) 212 } else if !parsedURL.IsAbs() { 213 errs = append(errs, fmt.Errorf("repository URL is not absolute: %s TXT %q", allowlistHostname, record)) 214 } else { 215 repoURLs = append(repoURLs, record) 216 } 217 } 218 219 if len(repoURLs) == 0 { 220 if len(records) > 0 { 221 errs = append([]error{AuthError{http.StatusUnauthorized, 222 fmt.Sprintf("no valid DNS TXT records for %s", allowlistHostname)}}, 223 errs...) 224 return nil, joinErrors(errs...) 225 } else { 226 return nil, AuthError{http.StatusUnauthorized, 227 fmt.Sprintf("no DNS TXT records found for %s", allowlistHostname)} 228 } 229 } 230 231 return &Authorization{ 232 repoURLs: repoURLs, 233 branch: "pages", 234 }, err 235} 236 237// used for `/.git-pages/...` metadata 238func authorizeWildcardMatchHost(r *http.Request, pattern *WildcardPattern) (*Authorization, error) { 239 host, err := GetHost(r) 240 if err != nil { 241 return nil, err 242 } 243 244 if _, found := pattern.Matches(host); found { 245 return &Authorization{ 246 repoURLs: []string{}, 247 branch: "", 248 }, nil 249 } else { 250 return nil, AuthError{ 251 http.StatusUnauthorized, 252 fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()), 253 } 254 } 255} 256 257// used for updates to site content 258func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Authorization, error) { 259 host, err := GetHost(r) 260 if err != nil { 261 return nil, err 262 } 263 264 projectName, err := GetProjectName(r) 265 if err != nil { 266 return nil, err 267 } 268 269 if userName, found := pattern.Matches(host); found { 270 repoURL, branch := pattern.ApplyTemplate(userName, projectName) 271 return &Authorization{repoURLs: []string{repoURL}, branch: branch}, nil 272 } else { 273 return nil, AuthError{ 274 http.StatusUnauthorized, 275 fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()), 276 } 277 } 278} 279 280// used for compatibility with Codeberg Pages v2 281// see https://docs.codeberg.org/codeberg-pages/using-custom-domain/ 282func authorizeCodebergPagesV2(r *http.Request) (*Authorization, error) { 283 host, err := GetHost(r) 284 if err != nil { 285 return nil, err 286 } 287 288 dnsRecords := []string{} 289 290 cnameRecord, err := net.LookupCNAME(host) 291 // "LookupCNAME does not return an error if host does not contain DNS "CNAME" records, 292 // as long as host resolves to address records. 293 if err == nil && cnameRecord != host { 294 // LookupCNAME() returns a domain with the root label, i.e. `username.codeberg.page.`, 295 // with the trailing dot 296 dnsRecords = append(dnsRecords, strings.TrimSuffix(cnameRecord, ".")) 297 } 298 299 txtRecords, err := net.LookupTXT(host) 300 if err == nil { 301 dnsRecords = append(dnsRecords, txtRecords...) 302 } 303 304 if len(dnsRecords) > 0 { 305 logc.Printf(r.Context(), "auth: %s TXT/CNAME: %q\n", host, dnsRecords) 306 } 307 308 for _, dnsRecord := range dnsRecords { 309 domainParts := strings.Split(dnsRecord, ".") 310 slices.Reverse(domainParts) 311 if domainParts[0] == "" { 312 domainParts = domainParts[1:] 313 } 314 if len(domainParts) >= 3 && len(domainParts) <= 5 { 315 if domainParts[0] == "page" && domainParts[1] == "codeberg" { 316 // map of domain names to allowed repository and branch: 317 // * {username}.codeberg.page => 318 // https://codeberg.org/{username}/pages.git#main 319 // * {reponame}.{username}.codeberg.page => 320 // https://codeberg.org/{username}/{reponame}.git#pages 321 // * {branch}.{reponame}.{username}.codeberg.page => 322 // https://codeberg.org/{username}/{reponame}.git#{branch} 323 username := domainParts[2] 324 reponame := "pages" 325 branch := "main" 326 if len(domainParts) >= 4 { 327 reponame = domainParts[3] 328 branch = "pages" 329 } 330 if len(domainParts) == 5 { 331 branch = domainParts[4] 332 } 333 return &Authorization{ 334 repoURLs: []string{ 335 fmt.Sprintf("https://codeberg.org/%s/%s.git", username, reponame), 336 }, 337 branch: branch, 338 }, nil 339 } 340 } 341 } 342 343 return nil, AuthError{ 344 http.StatusUnauthorized, 345 fmt.Sprintf("domain %s does not have Codeberg Pages TXT or CNAME records", host), 346 } 347} 348 349// Checks whether an operation that enables enumerating site contents is allowed. 350func AuthorizeMetadataRetrieval(r *http.Request) (*Authorization, error) { 351 causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} 352 353 auth := authorizeInsecure(r) 354 if auth != nil { 355 return auth, nil 356 } 357 358 auth, err := authorizeDNSChallenge(r) 359 if err != nil && IsUnauthorized(err) { 360 causes = append(causes, err) 361 } else if err != nil { // bad request 362 return nil, err 363 } else { 364 logc.Println(r.Context(), "auth: DNS challenge") 365 return auth, nil 366 } 367 368 for _, pattern := range wildcards { 369 auth, err = authorizeWildcardMatchHost(r, pattern) 370 if err != nil && IsUnauthorized(err) { 371 causes = append(causes, err) 372 } else if err != nil { // bad request 373 return nil, err 374 } else { 375 logc.Printf(r.Context(), "auth: wildcard %s\n", pattern.GetHost()) 376 return auth, nil 377 } 378 } 379 380 if config.Feature("codeberg-pages-compat") { 381 auth, err = authorizeCodebergPagesV2(r) 382 if err != nil && IsUnauthorized(err) { 383 causes = append(causes, err) 384 } else if err != nil { // bad request 385 return nil, err 386 } else { 387 logc.Printf(r.Context(), "auth: codeberg %s\n", r.Host) 388 return auth, nil 389 } 390 } 391 392 return nil, joinErrors(causes...) 393} 394 395func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) { 396 causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} 397 398 if err := CheckForbiddenDomain(r); err != nil { 399 return nil, err 400 } 401 402 auth := authorizeInsecure(r) 403 if auth != nil { 404 return auth, nil 405 } 406 407 // DNS challenge gives absolute authority. 408 auth, err := authorizeDNSChallenge(r) 409 if err != nil && IsUnauthorized(err) { 410 causes = append(causes, err) 411 } else if err != nil { // bad request 412 return nil, err 413 } else { 414 logc.Println(r.Context(), "auth: DNS challenge: allow *") 415 return auth, nil 416 } 417 418 // DNS allowlist gives authority to update but not delete. 419 if r.Method == http.MethodPut || r.Method == http.MethodPost { 420 auth, err = authorizeDNSAllowlist(r) 421 if err != nil && IsUnauthorized(err) { 422 causes = append(causes, err) 423 } else if err != nil { // bad request 424 return nil, err 425 } else { 426 logc.Printf(r.Context(), "auth: DNS allowlist: allow %v\n", auth.repoURLs) 427 return auth, nil 428 } 429 } 430 431 // Wildcard match is only available for webhooks, not the REST API. 432 if r.Method == http.MethodPost { 433 for _, pattern := range wildcards { 434 auth, err = authorizeWildcardMatchSite(r, pattern) 435 if err != nil && IsUnauthorized(err) { 436 causes = append(causes, err) 437 } else if err != nil { // bad request 438 return nil, err 439 } else { 440 logc.Printf(r.Context(), "auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs) 441 return auth, nil 442 } 443 } 444 445 if config.Feature("codeberg-pages-compat") { 446 auth, err = authorizeCodebergPagesV2(r) 447 if err != nil && IsUnauthorized(err) { 448 causes = append(causes, err) 449 } else if err != nil { // bad request 450 return nil, err 451 } else { 452 logc.Printf(r.Context(), "auth: codeberg %s: allow %v branch %s\n", 453 r.Host, auth.repoURLs, auth.branch) 454 return auth, nil 455 } 456 } 457 } 458 459 return nil, joinErrors(causes...) 460} 461 462func checkAllowedURLPrefix(repoURL string) error { 463 if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 { 464 allowedPrefix := false 465 repoURL = strings.ToLower(repoURL) 466 for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes { 467 if strings.HasPrefix(repoURL, strings.ToLower(allowedRepoURLPrefix)) { 468 allowedPrefix = true 469 break 470 } 471 } 472 if !allowedPrefix { 473 return AuthError{ 474 http.StatusUnauthorized, 475 fmt.Sprintf("clone URL not in prefix allowlist %v", 476 config.Limits.AllowedRepositoryURLPrefixes), 477 } 478 } 479 } 480 481 return nil 482} 483 484var repoURLSchemeAllowlist []string = []string{"ssh", "http", "https"} 485 486func AuthorizeRepository(repoURL string, auth *Authorization) error { 487 // Regardless of any other authorization, only the allowlisted URL schemes 488 // may ever be cloned from, so this check has to come first. 489 parsedRepoURL, err := url.Parse(repoURL) 490 if err != nil { 491 if strings.HasPrefix(repoURL, "git@") { 492 return AuthError{http.StatusBadRequest, "malformed clone URL; use ssh:// scheme"} 493 } else { 494 return AuthError{http.StatusBadRequest, "malformed clone URL"} 495 } 496 } 497 if !slices.Contains(repoURLSchemeAllowlist, parsedRepoURL.Scheme) { 498 return AuthError{ 499 http.StatusUnauthorized, 500 fmt.Sprintf("clone URL scheme not in allowlist %v", 501 repoURLSchemeAllowlist), 502 } 503 } 504 505 if auth.repoURLs == nil { 506 return nil // any 507 } 508 509 if err = checkAllowedURLPrefix(repoURL); err != nil { 510 return err 511 } 512 513 allowed := false 514 repoURL = strings.ToLower(repoURL) 515 for _, allowedRepoURL := range auth.repoURLs { 516 if repoURL == strings.ToLower(allowedRepoURL) { 517 allowed = true 518 break 519 } 520 } 521 if !allowed { 522 return AuthError{ 523 http.StatusUnauthorized, 524 fmt.Sprintf("clone URL not in allowlist %v", auth.repoURLs), 525 } 526 } 527 528 return nil 529} 530 531// The purpose of `allowRepoURLs` is to make sure that only authorized content is deployed 532// to the site despite the fact that the non-shared-secret authorization methods allow anyone 533// to impersonate the legitimate webhook sender. (If switching to another repository URL would 534// be catastrophic, then so would be switching to a different branch.) 535func AuthorizeBranch(branch string, auth *Authorization) error { 536 if auth.repoURLs == nil { 537 return nil // any 538 } 539 540 if branch == auth.branch { 541 return nil 542 } else { 543 return AuthError{ 544 http.StatusUnauthorized, 545 fmt.Sprintf("branch %s not in allowlist %v", branch, []string{auth.branch}), 546 } 547 } 548} 549 550// Gogs, Gitea, and Forgejo all support the same API here. 551func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) error { 552 ownerAndRepo := strings.TrimSuffix(strings.TrimPrefix(baseURL.Path, "/"), ".git") 553 request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{ 554 Path: fmt.Sprintf("/api/v1/repos/%s", ownerAndRepo), 555 }).String(), nil) 556 if err != nil { 557 panic(err) // misconfiguration 558 } 559 request.Header.Set("Accept", "application/json") 560 request.Header.Set("Authorization", authorization) 561 562 httpClient := http.Client{Timeout: 5 * time.Second} 563 response, err := httpClient.Do(request) 564 if err != nil { 565 return AuthError{ 566 http.StatusServiceUnavailable, 567 fmt.Sprintf("cannot check repository permissions: %s", err), 568 } 569 } 570 defer response.Body.Close() 571 572 if response.StatusCode == http.StatusNotFound { 573 return AuthError{ 574 http.StatusNotFound, 575 fmt.Sprintf("no repository %s", ownerAndRepo), 576 } 577 } else if response.StatusCode != http.StatusOK { 578 return AuthError{ 579 http.StatusServiceUnavailable, 580 fmt.Sprintf( 581 "cannot check repository permissions: GET %s returned %s", 582 request.URL, 583 response.Status, 584 ), 585 } 586 } 587 decoder := json.NewDecoder(response.Body) 588 589 var repositoryInfo struct{ Permissions struct{ Push bool } } 590 if err = decoder.Decode(&repositoryInfo); err != nil { 591 return errors.Join(AuthError{ 592 http.StatusServiceUnavailable, 593 fmt.Sprintf( 594 "cannot check repository permissions: GET %s returned malformed JSON", 595 request.URL, 596 ), 597 }, err) 598 } 599 600 if !repositoryInfo.Permissions.Push { 601 return AuthError{ 602 http.StatusUnauthorized, 603 fmt.Sprintf("no push permission for %s", ownerAndRepo), 604 } 605 } 606 607 // this token authorizes pushing to the repo, yay! 608 return nil 609} 610 611// Gogs, Gitea, and Forgejo all support the same API here. 612func fetchGogsAuthorizedUser(baseURL *url.URL, authorization string) (*ForgeUser, error) { 613 request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{ 614 Path: "/api/v1/user", 615 }).String(), nil) 616 if err != nil { 617 panic(err) // misconfiguration 618 } 619 request.Header.Set("Accept", "application/json") 620 request.Header.Set("Authorization", authorization) 621 622 httpClient := http.Client{Timeout: 5 * time.Second} 623 response, err := httpClient.Do(request) 624 if err != nil { 625 return nil, AuthError{ 626 http.StatusServiceUnavailable, 627 fmt.Sprintf("cannot fetch authorized forge user: %s", err), 628 } 629 } 630 defer response.Body.Close() 631 632 if response.StatusCode != http.StatusOK { 633 return nil, AuthError{ 634 http.StatusServiceUnavailable, 635 fmt.Sprintf( 636 "cannot fetch authorized forge user: GET %s returned %s", 637 request.URL, 638 response.Status, 639 ), 640 } 641 } 642 decoder := json.NewDecoder(response.Body) 643 644 var userInfo struct { 645 ID int64 646 Login string 647 } 648 if err = decoder.Decode(&userInfo); err != nil { 649 return nil, errors.Join(AuthError{ 650 http.StatusServiceUnavailable, 651 fmt.Sprintf( 652 "cannot fetch authorized forge user: GET %s returned malformed JSON", 653 request.URL, 654 ), 655 }, err) 656 } 657 658 origin := request.URL.Hostname() 659 return &ForgeUser{ 660 Origin: &origin, 661 Id: &userInfo.ID, 662 Handle: &userInfo.Login, 663 }, nil 664} 665 666func authorizeForgeWithToken(r *http.Request) (*Authorization, error) { 667 authorization := r.Header.Get("Forge-Authorization") 668 if authorization == "" { 669 return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"} 670 } 671 672 host, err := GetHost(r) 673 if err != nil { 674 return nil, err 675 } 676 677 projectName, err := GetProjectName(r) 678 if err != nil { 679 return nil, err 680 } 681 682 var errs []error 683 for _, pattern := range wildcards { 684 if !pattern.Authorization { 685 continue 686 } 687 688 if userName, found := pattern.Matches(host); found { 689 repoURL, branch := pattern.ApplyTemplate(userName, projectName) 690 parsedRepoURL, err := url.Parse(repoURL) 691 if err != nil { 692 panic(err) // misconfiguration 693 } 694 695 if err = checkGogsRepositoryPushPermission(parsedRepoURL, authorization); err != nil { 696 errs = append(errs, err) 697 continue 698 } 699 700 authorizedUser, err := fetchGogsAuthorizedUser(parsedRepoURL, authorization) 701 if err != nil { 702 errs = append(errs, err) 703 continue 704 } 705 706 return &Authorization{ 707 // This will actually be ignored by the callers of AuthorizeUpdateFromArchive and 708 // AuthorizeDeletion, but we return this information as it makes sense to do 709 // contextually here. 710 repoURLs: []string{repoURL}, 711 branch: branch, 712 713 forgeUser: authorizedUser, 714 }, nil 715 } 716 } 717 718 errs = append([]error{ 719 AuthError{http.StatusUnauthorized, "not authorized by forge"}, 720 }, errs...) 721 return nil, joinErrors(errs...) 722} 723 724func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) { 725 causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} 726 727 if err := CheckForbiddenDomain(r); err != nil { 728 return nil, err 729 } 730 731 auth := authorizeInsecure(r) 732 if auth != nil { 733 return auth, nil 734 } 735 736 // Token authorization allows updating a site on a wildcard domain from an archive. 737 auth, err := authorizeForgeWithToken(r) 738 if err != nil && IsUnauthorized(err) { 739 causes = append(causes, err) 740 } else if err != nil { // bad request 741 return nil, err 742 } else { 743 logc.Printf(r.Context(), "auth: forge token: allow\n") 744 return auth, nil 745 } 746 747 if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 { 748 causes = append(causes, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"}) 749 } else { 750 // DNS challenge gives absolute authority. 751 auth, err = authorizeDNSChallenge(r) 752 if err != nil && IsUnauthorized(err) { 753 causes = append(causes, err) 754 } else if err != nil { // bad request 755 return nil, err 756 } else { 757 logc.Println(r.Context(), "auth: DNS challenge") 758 return auth, nil 759 } 760 } 761 762 return nil, joinErrors(causes...) 763} 764 765func AuthorizeDeletion(r *http.Request) (*Authorization, error) { 766 causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} 767 768 if err := CheckForbiddenDomain(r); err != nil { 769 return nil, err 770 } 771 772 auth := authorizeInsecure(r) 773 if auth != nil { 774 return auth, nil 775 } 776 777 auth, err := authorizeDNSChallenge(r) 778 if err != nil && IsUnauthorized(err) { 779 causes = append(causes, err) 780 } else if err != nil { // bad request 781 return nil, err 782 } else { 783 logc.Printf(r.Context(), "auth: DNS challenge: allow *\n") 784 return auth, nil 785 } 786 787 auth, err = authorizeForgeWithToken(r) 788 if err != nil && IsUnauthorized(err) { 789 causes = append(causes, err) 790 } else if err != nil { // bad request 791 return nil, err 792 } else { 793 logc.Printf(r.Context(), "auth: forge token: allow\n") 794 return auth, nil 795 } 796 797 return nil, joinErrors(causes...) 798} 799 800func CheckForbiddenDomain(r *http.Request) error { 801 host, err := GetHost(r) 802 if err != nil { 803 return err 804 } 805 806 host = strings.ToLower(host) 807 for _, reservedDomain := range config.Limits.ForbiddenDomains { 808 reservedDomain = strings.ToLower(reservedDomain) 809 if host == reservedDomain || strings.HasSuffix(host, fmt.Sprintf(".%s", reservedDomain)) { 810 return AuthError{http.StatusForbidden, "forbidden domain"} 811 } 812 } 813 814 return nil 815}