package git_pages import ( "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "slices" "strings" "time" "golang.org/x/net/idna" ) type AuthError struct { code int error string } func (e AuthError) Error() string { return e.error } func IsUnauthorized(err error) bool { var authErr AuthError if errors.As(err, &authErr) { return authErr.code == http.StatusUnauthorized } return false } func authorizeInsecure(r *http.Request) *Authorization { if config.Insecure { // for testing only logc.Println(r.Context(), "auth: INSECURE mode") return &Authorization{ repoURLs: nil, branch: "pages", } } return nil } var idnaProfile = idna.New(idna.MapForLookup(), idna.BidiRule()) func GetHost(r *http.Request) (string, error) { host, _, err := net.SplitHostPort(r.Host) if err != nil { host = r.Host } // this also rejects invalid characters and labels host, err = idnaProfile.ToASCII(host) if err != nil { if config.Feature("relaxed-idna") { // unfortunately, the go IDNA library has some significant issues around its // Unicode TR46 implementation: https://github.com/golang/go/issues/76804 // we would like to allow *just* the _ here, but adding `idna.StrictDomainName(false)` // would also accept domains like `*.foo.bar` which should clearly be disallowed. // as a workaround, accept a domain name if it is valid with all `_` characters // replaced with an alphanumeric character (we use `a`); this allows e.g. `foo_bar.xxx` // and `foo__bar.xxx`, as well as `_foo.xxx` and `foo_.xxx`. labels starting with // an underscore are explicitly rejected below. _, err = idnaProfile.ToASCII(strings.ReplaceAll(host, "_", "a")) } if err != nil { return "", AuthError{http.StatusBadRequest, fmt.Sprintf("malformed host name %q", host)} } } if strings.HasPrefix(host, ".") || strings.HasPrefix(host, "_") { return "", AuthError{http.StatusBadRequest, fmt.Sprintf("reserved host name %q", host)} } host = strings.TrimSuffix(host, ".") return host, nil } func IsValidProjectName(name string) bool { return !strings.HasPrefix(name, ".") && !strings.Contains(name, "%") } func GetProjectName(r *http.Request) (string, error) { // path must be either `/` or `/foo/` (`/foo` is accepted as an alias) path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/") if !IsValidProjectName(path) { return "", AuthError{http.StatusBadRequest, fmt.Sprintf("directory name %q is reserved", ".index")} } else if strings.Contains(path, "/") { return "", AuthError{http.StatusBadRequest, "directories nested too deep"} } if path == "" { // path `/` corresponds to pseudo-project `.index` return ".index", nil } else { return path, nil } } type Authorization struct { // If `nil`, any URL is allowed. If not, only those in the set are allowed. repoURLs []string // Only the exact branch is allowed. branch string // The authorized forge user. forgeUser *ForgeUser } func authorizeDNSChallenge(r *http.Request) (*Authorization, error) { host, err := GetHost(r) if err != nil { return nil, err } authorization := r.Header.Get("Authorization") if authorization == "" { return nil, AuthError{http.StatusUnauthorized, "missing Authorization header"} } scheme, param, success := strings.Cut(authorization, " ") if !success { return nil, AuthError{http.StatusBadRequest, "malformed Authorization header"} } if scheme != "Pages" && scheme != "Basic" { return nil, AuthError{http.StatusBadRequest, "unknown Authorization scheme"} } // services like GitHub and Gogs cannot send a custom Authorization: header, but supplying // username and password in the URL is basically just as good if scheme == "Basic" { basicParam, err := base64.StdEncoding.DecodeString(param) if err != nil { return nil, AuthError{http.StatusBadRequest, "malformed Authorization: Basic header"} } username, password, found := strings.Cut(string(basicParam), ":") if !found { return nil, AuthError{http.StatusBadRequest, "malformed Authorization: Basic parameter"} } if username != "Pages" { return nil, AuthError{http.StatusUnauthorized, "unexpected Authorization: Basic username"} } param = password } challengeHostname := fmt.Sprintf("_git-pages-challenge.%s", host) actualChallenges, err := net.LookupTXT(challengeHostname) if err != nil { return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf("failed to look up DNS challenge: %s TXT", challengeHostname)} } expectedChallenge := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s %s", host, param))) if !slices.Contains(actualChallenges, expectedChallenge) { return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf( "defeated by DNS challenge: %s TXT %v does not include %s", challengeHostname, actualChallenges, expectedChallenge, )} } return &Authorization{ repoURLs: nil, // any branch: "pages", }, nil } func authorizeDNSAllowlist(r *http.Request, scope string) (*Authorization, error) { host, err := GetHost(r) if err != nil { return nil, err } projectName, err := GetProjectName(r) if err != nil { return nil, err } allowlistHostname := fmt.Sprintf("_%s.%s", scope, host) records, err := net.LookupTXT(allowlistHostname) if err != nil { return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf("failed to look up DNS repository allowlist: %s TXT", allowlistHostname)} } if projectName != ".index" { return nil, AuthError{http.StatusUnauthorized, "DNS repository allowlist only authorizes index site"} } var ( repoURLs []string errs []error ) for _, record := range records { if parsedURL, err := url.Parse(record); err != nil { errs = append(errs, fmt.Errorf("failed to parse URL: %s TXT %q", allowlistHostname, record)) } else if !parsedURL.IsAbs() { errs = append(errs, fmt.Errorf("repository URL is not absolute: %s TXT %q", allowlistHostname, record)) } else { repoURLs = append(repoURLs, record) } } if len(repoURLs) == 0 { if len(records) > 0 { errs = append([]error{AuthError{http.StatusUnauthorized, fmt.Sprintf("no valid DNS TXT records for %s", allowlistHostname)}}, errs...) return nil, joinErrors(errs...) } else { return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf("no DNS TXT records found for %s", allowlistHostname)} } } return &Authorization{ repoURLs: repoURLs, branch: "pages", }, err } // used for `/.git-pages/...` metadata func authorizeWildcardMatchHost(r *http.Request, pattern *WildcardPattern) (*Authorization, error) { host, err := GetHost(r) if err != nil { return nil, err } if _, found := pattern.Matches(host); found { return &Authorization{ repoURLs: []string{}, branch: "", }, nil } else { return nil, AuthError{ http.StatusUnauthorized, fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()), } } } // used for updates to site content func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Authorization, error) { host, err := GetHost(r) if err != nil { return nil, err } projectName, err := GetProjectName(r) if err != nil { return nil, err } if userName, found := pattern.Matches(host); found { repoURL, branch := pattern.ApplyTemplate(userName, projectName) return &Authorization{repoURLs: []string{repoURL}, branch: branch}, nil } else { return nil, AuthError{ http.StatusUnauthorized, fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()), } } } // used for compatibility with Codeberg Pages v2 // see https://docs.codeberg.org/codeberg-pages/using-custom-domain/ func authorizeCodebergPagesV2(r *http.Request) (*Authorization, error) { host, err := GetHost(r) if err != nil { return nil, err } dnsRecords := []string{} cnameRecord, err := net.LookupCNAME(host) // "LookupCNAME does not return an error if host does not contain DNS "CNAME" records, // as long as host resolves to address records. if err == nil && cnameRecord != host { // LookupCNAME() returns a domain with the root label, i.e. `username.codeberg.page.`, // with the trailing dot dnsRecords = append(dnsRecords, strings.TrimSuffix(cnameRecord, ".")) } txtRecords, err := net.LookupTXT(host) if err == nil { dnsRecords = append(dnsRecords, txtRecords...) } if len(dnsRecords) > 0 { logc.Printf(r.Context(), "auth: %s TXT/CNAME: %q\n", host, dnsRecords) } for _, dnsRecord := range dnsRecords { domainParts := strings.Split(dnsRecord, ".") slices.Reverse(domainParts) if domainParts[0] == "" { domainParts = domainParts[1:] } if len(domainParts) >= 3 && len(domainParts) <= 5 { if domainParts[0] == "page" && domainParts[1] == "codeberg" { // map of domain names to allowed repository and branch: // * {username}.codeberg.page => // https://codeberg.org/{username}/pages.git#pages // * {reponame}.{username}.codeberg.page => // https://codeberg.org/{username}/{reponame}.git#pages // * {branch}.{reponame}.{username}.codeberg.page => // https://codeberg.org/{username}/{reponame}.git#{branch} username := domainParts[2] reponame := "pages" branch := "pages" if len(domainParts) >= 4 { reponame = domainParts[3] } if len(domainParts) == 5 { branch = domainParts[4] } return &Authorization{ repoURLs: []string{ fmt.Sprintf("https://codeberg.org/%s/%s.git", username, reponame), }, branch: branch, }, nil } } } return nil, AuthError{ http.StatusUnauthorized, fmt.Sprintf("domain %s does not have Codeberg Pages TXT or CNAME records", host), } } // Checks whether an operation that enables enumerating site contents is allowed. func AuthorizeMetadataRetrieval(r *http.Request, hasBasicAuth bool) (*Authorization, error) { causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} auth := authorizeInsecure(r) if auth != nil { return auth, nil } auth, err := authorizeDNSChallenge(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Println(r.Context(), "auth: DNS challenge") return auth, nil } // Normally, sites that correspond to a forge via a wildcard match are considered completely // public and safe to retrieve without authorization. However, this is no longer the case if // they have password-protected sections. if !hasBasicAuth { for _, pattern := range wildcards { auth, err = authorizeWildcardMatchHost(r, pattern) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: wildcard %s\n", pattern.GetHost()) return auth, nil } } if config.Feature("codeberg-pages-compat") { auth, err = authorizeCodebergPagesV2(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: codeberg %s\n", r.Host) return auth, nil } } } return nil, joinErrors(causes...) } func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) { causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} if err := CheckForbiddenDomain(r); err != nil { return nil, err } auth := authorizeInsecure(r) if auth != nil { return auth, nil } // DNS challenge gives absolute authority. auth, err := authorizeDNSChallenge(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Println(r.Context(), "auth: DNS challenge: allow *") return auth, nil } // DNS allowlist gives authority to update but not delete. if r.Method == http.MethodPut || r.Method == http.MethodPost { auth, err = authorizeDNSAllowlist(r, "git-pages-repository") if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: DNS allowlist: allow %v\n", auth.repoURLs) return auth, nil } } // Wildcard match is only available for webhooks, not the REST API. if r.Method == http.MethodPost { for _, pattern := range wildcards { auth, err = authorizeWildcardMatchSite(r, pattern) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs) return auth, nil } } if config.Feature("codeberg-pages-compat") { auth, err = authorizeCodebergPagesV2(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: codeberg %s: allow %v branch %s\n", r.Host, auth.repoURLs, auth.branch) return auth, nil } } } return nil, joinErrors(causes...) } func checkAllowedURLPrefixes(repoURLs ...string) error { if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 { for _, repoURL := range repoURLs { allowedPrefix := false repoURL = strings.ToLower(repoURL) for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes { if strings.HasPrefix(repoURL, strings.ToLower(allowedRepoURLPrefix)) { allowedPrefix = true break } } if !allowedPrefix { return AuthError{ http.StatusUnauthorized, fmt.Sprintf("clone URL %v not in prefix allowlist %v", repoURL, config.Limits.AllowedRepositoryURLPrefixes), } } } } return nil } var repoURLSchemeAllowlist []string = []string{"ssh", "http", "https"} func AuthorizeRepository(repoURL string, auth *Authorization) error { // Regardless of any other authorization, only the allowlisted URL schemes // may ever be cloned from, so this check has to come first. parsedRepoURL, err := url.Parse(repoURL) if err != nil { if strings.HasPrefix(repoURL, "git@") { return AuthError{http.StatusBadRequest, "malformed clone URL; use ssh:// scheme"} } else { return AuthError{http.StatusBadRequest, "malformed clone URL"} } } if !slices.Contains(repoURLSchemeAllowlist, parsedRepoURL.Scheme) { return AuthError{ http.StatusUnauthorized, fmt.Sprintf("clone URL scheme not in allowlist %v", repoURLSchemeAllowlist), } } if auth.repoURLs == nil { return nil // any } if err = checkAllowedURLPrefixes(repoURL); err != nil { return err } allowed := false repoURL = strings.ToLower(repoURL) for _, allowedRepoURL := range auth.repoURLs { if repoURL == strings.ToLower(allowedRepoURL) { allowed = true break } } if !allowed { return AuthError{ http.StatusUnauthorized, fmt.Sprintf("clone URL not in allowlist %v", auth.repoURLs), } } return nil } // The purpose of `allowRepoURLs` is to make sure that only authorized content is deployed // to the site despite the fact that the non-shared-secret authorization methods allow anyone // to impersonate the legitimate webhook sender. (If switching to another repository URL would // be catastrophic, then so would be switching to a different branch.) func AuthorizeBranch(branch string, auth *Authorization) error { if auth.repoURLs == nil { return nil // any } if branch == auth.branch { return nil } else { return AuthError{ http.StatusUnauthorized, fmt.Sprintf("branch %s not in allowlist %v", branch, []string{auth.branch}), } } } // Gogs, Gitea, and Forgejo all support the same API here. func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) error { ownerAndRepo := strings.TrimSuffix(strings.TrimPrefix(baseURL.Path, "/"), ".git") request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{ Path: fmt.Sprintf("/api/v1/repos/%s", ownerAndRepo), }).String(), nil) if err != nil { panic(err) // misconfiguration } request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", authorization) httpClient := http.Client{Timeout: 5 * time.Second} response, err := httpClient.Do(request) if err != nil { return AuthError{ http.StatusServiceUnavailable, fmt.Sprintf("cannot check repository permissions: %s", err), } } defer response.Body.Close() if response.StatusCode == http.StatusNotFound { return AuthError{ http.StatusNotFound, fmt.Sprintf("no repository %s", ownerAndRepo), } } else if response.StatusCode == http.StatusUnauthorized { return AuthError{ http.StatusUnauthorized, fmt.Sprintf("no access to %s or invalid token", ownerAndRepo), } } else if response.StatusCode != http.StatusOK { return AuthError{ http.StatusServiceUnavailable, fmt.Sprintf( "cannot check repository permissions: GET %s returned %s", request.URL, response.Status, ), } } decoder := json.NewDecoder(response.Body) var repositoryInfo struct{ Permissions struct{ Push bool } } if err = decoder.Decode(&repositoryInfo); err != nil { return errors.Join(AuthError{ http.StatusServiceUnavailable, fmt.Sprintf( "cannot check repository permissions: GET %s returned malformed JSON", request.URL, ), }, err) } if !repositoryInfo.Permissions.Push { return AuthError{ http.StatusUnauthorized, fmt.Sprintf("no push permission for %s", ownerAndRepo), } } // this token authorizes pushing to the repo, yay! return nil } // Gogs, Gitea, and Forgejo all support the same API here. func fetchGogsAuthorizedUser(baseURL *url.URL, forgeToken string) (*ForgeUser, error) { request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{ Path: "/api/v1/user", }).String(), nil) if err != nil { panic(err) // misconfiguration } request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", forgeToken) httpClient := http.Client{Timeout: 5 * time.Second} response, err := httpClient.Do(request) if err != nil { return nil, AuthError{ http.StatusServiceUnavailable, fmt.Sprintf("cannot fetch authorized forge user: %s", err), } } defer response.Body.Close() if response.StatusCode != http.StatusOK { return nil, AuthError{ http.StatusServiceUnavailable, fmt.Sprintf( "cannot fetch authorized forge user: GET %s returned %s", request.URL, response.Status, ), } } decoder := json.NewDecoder(response.Body) var userInfo struct { ID int64 Login string } if err = decoder.Decode(&userInfo); err != nil { return nil, errors.Join(AuthError{ http.StatusServiceUnavailable, fmt.Sprintf( "cannot fetch authorized forge user: GET %s returned malformed JSON", request.URL, ), }, err) } origin := request.URL.Hostname() return &ForgeUser{ Origin: &origin, Id: &userInfo.ID, Handle: &userInfo.Login, }, nil } // Check whether a forge token has access to a repository, and if it does, which user it // belongs to. Precondition: `repoURL` is well-formed. func authorizeGogsUser(repoURL string, forgeToken string) (*Authorization, error) { parsedRepoURL, err := url.Parse(repoURL) if err != nil { panic(err) } if err = checkGogsRepositoryPushPermission(parsedRepoURL, forgeToken); err != nil { return nil, err } authorizedUser, err := fetchGogsAuthorizedUser(parsedRepoURL, forgeToken) if err != nil { return nil, err } return &Authorization{ repoURLs: []string{repoURL}, forgeUser: authorizedUser, }, nil } // Validates a provided forge token against a repository URL constructed by mapping the host // and project name via the `[[wildcard]]` section of the configuration file. func authorizeForgeWildcard(r *http.Request) (*Authorization, error) { forgeToken := r.Header.Get("Forge-Authorization") if forgeToken == "" { return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"} } host, err := GetHost(r) if err != nil { return nil, err } projectName, err := GetProjectName(r) if err != nil { return nil, err } var errs []error for _, pattern := range wildcards { if pattern.Authorization { if userName, found := pattern.Matches(host); found { repoURL, branch := pattern.ApplyTemplate(userName, projectName) auth, err := authorizeGogsUser(repoURL, forgeToken) if err != nil { errs = append(errs, err) } else { auth.branch = branch return auth, nil } } } } if len(errs) == 0 { errs = append(errs, AuthError{http.StatusUnauthorized, "no matching wildcard domain"}) } errs = append([]error{ AuthError{http.StatusUnauthorized, "not authorized by forge (wildcard)"}, }, errs...) return nil, joinErrors(errs...) } // Validates a provided forge token against a repository URL extracted from the DNS allowlist // records of the target domain specified in `_git-pages-forge-authorization.*`. func authorizeForgeDNSAllowlist(r *http.Request) (*Authorization, error) { forgeToken := r.Header.Get("Forge-Authorization") if forgeToken == "" { return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"} } var errs []error if dnsAuth, err := authorizeDNSAllowlist(r, "git-pages-forge-allowlist"); err != nil { errs = append(errs, err) } else if dnsAuth != nil { // DNS allows uploads from some repositories, but we don't know yet if the forge token // has a push permission to any of these repositories. for _, repoURL := range dnsAuth.repoURLs { auth, err := authorizeGogsUser(repoURL, forgeToken) if err != nil { errs = append(errs, err) } else { // There is both DNS authorization and forge authorization. return auth, nil } } } errs = append([]error{ AuthError{http.StatusUnauthorized, "not authorized by forge (DNS allowlist)"}, }, errs...) return nil, joinErrors(errs...) } func authorizeDNSChallengeOrForgeWithToken(r *http.Request) (*Authorization, error) { causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} if err := CheckForbiddenDomain(r); err != nil { return nil, err } auth := authorizeInsecure(r) if auth != nil { return auth, nil } // DNS challenge gives absolute authority. auth, err := authorizeDNSChallenge(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Println(r.Context(), "auth: DNS challenge: allow *") return auth, nil } // Token authorization allows updating a site on a wildcard domain from an archive. // This sub-method uses the `[[wildcard]]` configuration section to derive repository URL. auth, err = authorizeForgeWildcard(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: forge (wildcard): allow\n") return auth, nil } // Token authorization allows updating a site on a wildcard domain from an archive. // This sub-method uses the DNS allowlist authorization mechanism to derive repository URL. auth, err = authorizeForgeDNSAllowlist(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { logc.Printf(r.Context(), "auth: forge (DNS allowlist): allow\n") return auth, nil } return nil, joinErrors(causes...) } func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) { auth, err := authorizeDNSChallengeOrForgeWithToken(r) if err != nil { return nil, err } // If only uploads from specific repositories are allowed, then only forge authorization // is acceptable, and the repository must match the configured limits. if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 { if len(auth.repoURLs) == 0 { logc.Println(r.Context(), "auth: DNS challenge: deny (limits)") return nil, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"} } if err = checkAllowedURLPrefixes(auth.repoURLs...); err != nil { return nil, err } } return auth, nil } func AuthorizeDeletion(r *http.Request) (*Authorization, error) { return authorizeDNSChallengeOrForgeWithToken(r) } func CheckForbiddenDomain(r *http.Request) error { host, err := GetHost(r) if err != nil { return err } host = strings.ToLower(host) for _, reservedDomain := range config.Limits.ForbiddenDomains { reservedDomain = strings.ToLower(reservedDomain) if host == reservedDomain || strings.HasSuffix(host, fmt.Sprintf(".%s", reservedDomain)) { return AuthError{http.StatusForbidden, "forbidden domain"} } } return nil }