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