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 }