From 0b2db170b8263a1ab29d727cb7ab9bf45eaac8e0 Mon Sep 17 00:00:00 2001 From: Catherine Date: Wed, 19 Nov 2025 03:43:57 +0000 Subject: [PATCH] Allow updating wildcard domain sites from an archive with a forge token. --- README.md | 5 +- conf/config.example.toml | 1 + src/auth.go | 139 +++++++++++++++++++++++++++++++++++++-- src/config.go | 1 + src/wildcard.go | 40 +++++++---- 5 files changed, 165 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 01d2464..c587a2d 100644 --- a/README.md +++ b/README.md @@ -82,16 +82,17 @@ The authorization flow for content updates (`PUT`, `DELETE`, `POST` requests) pr - **`Pages` scheme:** Request includes an `Authorization: Pages ` header. - **`Basic` scheme:** Request includes an `Authorization: Basic ` header, where `` is equal to `Base64("Pages:")`. (Useful for non-Forgejo forges.) 3. **DNS Allowlist:** If the method is `PUT` or `POST`, and a TXT record lookup at `_git-pages-repository.` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized. -4. **Wildcard Match (Site):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. +4. **Wildcard Match (Webhook):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. - **Index repository:** If the request URL is `scheme://./`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `` and ``, where `` is computed by templating each element of `[[wildcard]].index-repos` with ``, and `[[wildcard]]` is the section where the match occurred. - **Project repository:** If the request URL is `scheme://.//`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `` and ``, and `[[wildcard]]` is the section where the match occurred. +5. **Forge Authorization:** If the method is `PUT`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized. 5. **Default Deny:** Otherwise, the request is not authorized. The authorization flow for metadata retrieval (`GET` requests with site paths starting with `.git-pages/`) in the following order, with the first of multiple applicable rule taking precedence: 1. **Development Mode:** Same as for content updates. 2. **DNS Challenge:** Same as for content updates. -3. **Wildcard Match (Domain):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, the request is authorized. +3. **Wildcard Match (Metadata):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, the request is authorized. 4. **Default Deny:** Otherwise, the request is not authorized. diff --git a/conf/config.example.toml b/conf/config.example.toml index ab59727..98f2fd2 100644 --- a/conf/config.example.toml +++ b/conf/config.example.toml @@ -14,6 +14,7 @@ domain = "codeberg.page" clone-url = "https://codeberg.org//.git" index-repos = [".codeberg.page", "pages"] index-repo-branch = "main" +authorization = "forgejo" fallback-proxy-to = "https://codeberg.page" [storage] diff --git a/src/auth.go b/src/auth.go index 73b4d59..8898dc7 100644 --- a/src/auth.go +++ b/src/auth.go @@ -3,6 +3,7 @@ package git_pages import ( "crypto/sha256" "encoding/base64" + "encoding/json" "errors" "fmt" "log" @@ -11,6 +12,7 @@ import ( "net/url" "slices" "strings" + "time" ) type AuthError struct { @@ -511,6 +513,118 @@ func AuthorizeBranch(branch string, auth *Authorization) error { } } +// 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.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 +} + +func authorizeForgeWithToken(r *http.Request) (*Authorization, error) { + authorization := r.Header.Get("Forge-Authorization") + if authorization == "" { + 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 wildcardPatterns { + if !pattern.Authorization { + continue + } + + if userName, found := pattern.Matches(host); found { + repoURLs, branch := pattern.ApplyTemplate(userName, projectName) + for _, repoURL := range repoURLs { + parsedRepoURL, err := url.Parse(repoURL) + if err != nil { + panic(err) // misconfiguration + } + + if err = checkGogsRepositoryPushPermission(parsedRepoURL, authorization); err != nil { + errs = append(errs, err) + continue + } + + // This will actually be ignored by the caller of AuthorizeUpdateFromArchive, + // but we return this information as it makes sense to do contextually here. + return &Authorization{ + []string{repoURL}, + branch, + }, nil + } + } + } + + errs = append([]error{ + AuthError{http.StatusUnauthorized, "not authorized by forge"}, + }, errs...) + return nil, joinErrors(errs...) +} + func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) { causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} @@ -523,21 +637,32 @@ func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) { return auth, nil } - if config.Limits.AllowedRepositoryURLPrefixes != nil { - return nil, AuthError{http.StatusUnauthorized, "updating from archive not allowed"} - } - - // DNS challenge gives absolute authority. - auth, err := authorizeDNSChallenge(r) + // Token authorization allows updating a site on a wildcard domain from an archive. + auth, err := authorizeForgeWithToken(r) if err != nil && IsUnauthorized(err) { causes = append(causes, err) } else if err != nil { // bad request return nil, err } else { - log.Println("auth: DNS challenge") + log.Printf("auth: forge token: allow\n") return auth, nil } + if config.Limits.AllowedRepositoryURLPrefixes != nil { + causes = append(causes, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"}) + } else { + // 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 { + log.Println("auth: DNS challenge") + return auth, nil + } + } + return nil, joinErrors(causes...) } diff --git a/src/config.go b/src/config.go index e478c30..1386eb4 100644 --- a/src/config.go +++ b/src/config.go @@ -56,6 +56,7 @@ type WildcardConfig struct { CloneURL string `toml:"clone-url"` IndexRepos []string `toml:"index-repos" default:"[]"` IndexRepoBranch string `toml:"index-repo-branch" default:"pages"` + Authorization string `toml:"authorization"` FallbackProxyTo string `toml:"fallback-proxy-to"` FallbackInsecure bool `toml:"fallback-insecure"` } diff --git a/src/wildcard.go b/src/wildcard.go index 0d979bc..2f19e0a 100644 --- a/src/wildcard.go +++ b/src/wildcard.go @@ -14,12 +14,13 @@ import ( ) type WildcardPattern struct { - Domain []string - CloneURL *fasttemplate.Template - IndexRepos []*fasttemplate.Template - IndexBranch string - FallbackURL *url.URL - Fallback http.Handler + Domain []string + CloneURL *fasttemplate.Template + IndexRepos []*fasttemplate.Template + IndexBranch string + Authorization bool + FallbackURL *url.URL + Fallback http.Handler } var wildcardPatterns []*WildcardPattern @@ -121,6 +122,20 @@ func ConfigureWildcards(configs []WildcardConfig) error { indexRepoTemplates = append(indexRepoTemplates, indexRepoTemplate) } + authorization := false + if config.Authorization != "" { + if slices.Contains([]string{"gogs", "gitea", "forgejo"}, config.Authorization) { + // Currently these are the only supported forges, and the authorization mechanism + // is the same for all of them. + authorization = true + } else { + return fmt.Errorf( + "wildcard pattern: unknown authorization mechanism: %s", + config.Authorization, + ) + } + } + var fallbackURL *url.URL var fallback http.Handler if config.FallbackProxyTo != "" { @@ -144,12 +159,13 @@ func ConfigureWildcards(configs []WildcardConfig) error { } wildcardPatterns = append(wildcardPatterns, &WildcardPattern{ - Domain: strings.Split(config.Domain, "."), - CloneURL: cloneURLTemplate, - IndexRepos: indexRepoTemplates, - IndexBranch: indexRepoBranch, - FallbackURL: fallbackURL, - Fallback: fallback, + Domain: strings.Split(config.Domain, "."), + CloneURL: cloneURLTemplate, + IndexRepos: indexRepoTemplates, + IndexBranch: indexRepoBranch, + Authorization: authorization, + FallbackURL: fallbackURL, + Fallback: fallback, }) } return nil