fork of whitequark.org/git-pages with mods for tangled
at main 19 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 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}