[mirror] Scalable static site server for Git forges (like GitHub Pages)
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}