From 3efb332351b912b88bad30970365c16554fe6431 Mon Sep 17 00:00:00 2001 From: Catherine Date: Mon, 25 May 2026 22:53:51 +0000 Subject: [PATCH] Implement pull request preview authorization. Requires `feature = ["preview"]`. --- README.md | 12 +++-- src/audit.go | 2 +- src/auth.go | 53 ++++++++++++++++++-- src/config.go | 1 + src/forge_api.go | 122 ++++++++++++++++++++++++++++++++++++++++++++--- src/schema.pb.go | 14 +++--- src/schema.proto | 2 +- src/wildcard.go | 82 ++++++++++++++++++++----------- 8 files changed, 235 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index b484279..735fcdd 100644 --- a/README.md +++ b/README.md @@ -121,17 +121,19 @@ The authorization flow for content updates (`PUT`, `PATCH`, `DELETE`, `POST` req - **`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 the request URL is `scheme://./`, 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 or (for `POST` requests) the body contains a GitHub-style webhook payload, and the requested clone URLs is contained in this set of URLs, and the requested branch is `pages`, the request is authorized. 4. **Wildcard Match (content):** If the method is `POST`, and the body contains a GitHub-style webhook payload, and a `[[wildcard]]` configuration section exists such that `[[wildcard]].domain` is a suffix of the site hostname (compared label-wise), and the body contains a repository URL, and the requested clone URL is a *matching* clone URL, and the requested branch is a *matching* branch, 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 `[[wildcard]].index-repo` with ``, and `[[wildcard]]` is the section where the match occurred; and a *matching* branch is specified by `[[wildcard]].index-repo-branch`. - - **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; and a *matching* branch is `pages`. + - **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 `[[wildcard]].index-repo` with ``; and a *matching* branch is specified by `[[wildcard]].index-repo-branch`. + - **Project repository:** If the request URL is `scheme://.//`: a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `` and ``; and a *matching* branch is `pages`. 5. **Forge Authorization (wildcard):** If the method is `PUT` or `PATCH` or `DELETE`, and (unless the method is `DELETE`) the body contains an archive, and a `[[wildcard]]` configuration section exists such that `[[wildcard]].domain` is a suffix of the site hostname (compared label-wise), and `[[wildcard]].authorization` is defined, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) 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. -6. **Forge Authorization (DNS allowlist):** If the method is `PUT` or `PATCH` or `DELETE`, and (unless the method is `DELETE`) the body contains an archive, and the request URL is `scheme:///`, and a TXT record lookup at `_git-pages-forge-allowlist.` returns a set of well-formed absolute URLs, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at any of the URLs in the TXT records as determined by an API call to the forge, the request is authorized. -7. **Default Deny:** Otherwise, the request is not authorized. +6. **Forge Authorization (wildcard, preview):** If the method is `PUT` or `PATCH` or `DELETE`, and (unless the method is `DELETE`) the body contains an archive, and a `[[wildcard]]` configuration section exists such that `[[wildcard]].preview-domain` is a suffix of the site hostname (compared label-wise), and `[[wildcard]].authorization` is `forgejo`, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) is mapped by the forge (via the `/api/v1/actions/run` endpoint) to a Forgejo Actions workflow run, and the workflow run corresponds to a *matching* pull request, the request is authorized. + - If the request URL is `scheme://./@/`: a *matching* pull request has a number `` and belongs to the repository with a clone URL computed by templating `[[wildcard]].clone-url` with `` and ``. +7. **Forge Authorization (DNS allowlist):** If the method is `PUT` or `PATCH` or `DELETE`, and (unless the method is `DELETE`) the body contains an archive, and the request URL is `scheme:///`, and a TXT record lookup at `_git-pages-forge-allowlist.` returns a set of well-formed absolute URLs, and the request includes a `Forge-Authorization:` header, and the header (when forwarded as `Authorization:`) grants push permissions to a repository at any of the URLs in the TXT records as determined by an API call to the forge, the request is authorized. +8. **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 (metadata):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and the site never uses the `Basic-Auth:` pseudo-header, 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` or `[[wildcard]].preview-domain`, and the site never uses the `Basic-Auth:` pseudo-header, the request is authorized. 4. **Default Deny:** Otherwise, the request is not authorized. diff --git a/src/audit.go b/src/audit.go index de0813e..3b76df9 100644 --- a/src/audit.go +++ b/src/audit.go @@ -108,7 +108,7 @@ func (record *AuditRecord) DescribePrincipal() string { if record.Principal.GetForgeUser() != nil { items = append(items, fmt.Sprintf("%s/%s(%d)", record.Principal.GetForgeUser().GetOrigin(), - record.Principal.GetForgeUser().GetHandle(), + record.Principal.GetForgeUser().GetName(), record.Principal.GetForgeUser().GetId())) } if record.Principal.GetRepoUrl() != "" { diff --git a/src/auth.go b/src/auth.go index 2519ba1..49bba1c 100644 --- a/src/auth.go +++ b/src/auth.go @@ -256,7 +256,7 @@ func authorizeWildcardMatchHost(r *http.Request, pattern *WildcardPattern) (*Aut return nil, err } - if _, found := pattern.Matches(host); found { + if _, found := pattern.Matches(host, WildcardDomainAny); found { return &Authorization{ repoURLs: []string{}, branch: "", @@ -281,7 +281,7 @@ func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Aut return nil, err } - if userName, found := pattern.Matches(host); found { + if userName, found := pattern.Matches(host, WildcardDomainPrimary); found { repoURL, branch := pattern.ApplyTemplate(userName, projectName) return &Authorization{repoURLs: []string{repoURL}, branch: branch}, nil } else { @@ -583,7 +583,7 @@ func authorizeGogsUser(repoURL string, forgeToken string) (*Authorization, error ownerAndRepo := strings.TrimPrefix(path.Clean(parsedRepoURL.Path), "/") owner, _, found := strings.Cut(ownerAndRepo, "/") - if found && owner == authorizedUser.GetHandle() { + if found && owner == authorizedUser.GetName() { // The authorized user owns any repository in their namespace. return &Authorization{ repoURLs: []string{repoURL}, @@ -601,6 +601,31 @@ func authorizeGogsUser(repoURL string, forgeToken string) (*Authorization, error }, nil } +func authorizeForgejoActionRun(repoURL string, forgeToken string, prNumber string) ( + *Authorization, error, +) { + parsedRepoURL, err := url.Parse(repoURL) + if err != nil { + panic(err) + } + + actionRun, err := FetchForgejoActionRun(parsedRepoURL, forgeToken) + if err != nil { + return nil, err + } + if actionRun.PullRequest == nil { + return nil, AuthError{http.StatusUnauthorized, "workflow run not triggered by pull request"} + } + if fmt.Sprintf("%d", actionRun.PullRequest.Number) != prNumber { + return nil, AuthError{http.StatusUnauthorized, "pull request number does not match"} + } + + return &Authorization{ + repoURLs: []string{repoURL}, + forgeUser: actionRun.TriggerUser, + }, 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) { @@ -622,8 +647,9 @@ func authorizeForgeWildcard(r *http.Request) (*Authorization, error) { var errs []error for _, pattern := range wildcards { if pattern.Authorization { - if userName, found := pattern.Matches(host); found { - repoURL, branch := pattern.ApplyTemplate(userName, projectName) + if userName, found := pattern.Matches(host, WildcardDomainPrimary); found { + repoName := projectName + repoURL, branch := pattern.ApplyTemplate(userName, repoName) auth, err := authorizeGogsUser(repoURL, forgeToken) if err != nil { errs = append(errs, err) @@ -632,6 +658,23 @@ func authorizeForgeWildcard(r *http.Request) (*Authorization, error) { return auth, nil } } + + if userName, found := pattern.Matches(host, WildcardDomainPreview); found { + if repoName, atSuffix, foundAt := strings.Cut(projectName, "@"); !foundAt { + errs = append([]error{ + AuthError{http.StatusUnauthorized, "not a preview project name (no `@`)"}, + }, errs...) + } else { + repoURL, branch := pattern.ApplyTemplate(userName, repoName) + auth, err := authorizeForgejoActionRun(repoURL, forgeToken, atSuffix) + if err != nil { + errs = append(errs, err) + } else { + auth.branch = branch + return auth, nil + } + } + } } } if len(errs) == 0 { diff --git a/src/config.go b/src/config.go index c88cd89..5f209a2 100644 --- a/src/config.go +++ b/src/config.go @@ -80,6 +80,7 @@ type ServerConfig struct { type WildcardConfig struct { Domain string `toml:"domain"` + PreviewDomain string `toml:"preview-domain"` CloneURL string `toml:"clone-url"` // URL template, not an exact URL IndexRepo string `toml:"index-repo" default:"pages"` IndexRepoBranch string `toml:"index-repo-branch" default:"pages"` diff --git a/src/forge_api.go b/src/forge_api.go index c5ddc1f..5138ef5 100644 --- a/src/forge_api.go +++ b/src/forge_api.go @@ -48,12 +48,12 @@ func FetchGogsAuthorizedUser(baseURL *url.URL, authorization string) (*ForgeUser ), } } - decoder := json.NewDecoder(response.Body) var userInfo struct { - ID int64 - Login string + ID int64 `json:"id"` + Login string `json:"login"` } + decoder := json.NewDecoder(response.Body) if err := decoder.Decode(&userInfo); err != nil { return nil, errors.Join(AuthError{ http.StatusServiceUnavailable, @@ -68,7 +68,7 @@ func FetchGogsAuthorizedUser(baseURL *url.URL, authorization string) (*ForgeUser return &ForgeUser{ Origin: &origin, Id: &userInfo.ID, - Handle: &userInfo.Login, + Name: &userInfo.Login, }, nil } @@ -105,9 +105,13 @@ func CheckGogsRepositoryPushPermission(baseURL *url.URL, authorization string) e ), } } - decoder := json.NewDecoder(response.Body) - var repositoryInfo struct{ Permissions struct{ Push bool } } + var repositoryInfo struct { + Permissions struct { + Push bool `json:"push"` + } `json:"permissions"` + } + decoder := json.NewDecoder(response.Body) if err := decoder.Decode(&repositoryInfo); err != nil { return errors.Join(AuthError{ http.StatusServiceUnavailable, @@ -128,3 +132,109 @@ func CheckGogsRepositoryPushPermission(baseURL *url.URL, authorization string) e // this token authorizes pushing to the repo, yay! return nil } + +type ForgeActionRun struct { + TriggerUser *ForgeUser + PullRequest *ForgePullRequest // only if `event == "pull_request"` +} + +type ForgePullRequest struct { + OwnerID int64 + OwnerName string + RepositoryID int64 + RepositoryName string + Number int64 +} + +// This is a Forgejo-specific API added in https://codeberg.org/forgejo/forgejo/pulls/12727 +// and available starting in Forgejo vX.Y. +func FetchForgejoActionRun(baseURL *url.URL, authorization string) (*ForgeActionRun, error) { + request, response, err := makeGogsAPIRequest(baseURL, authorization, "actions/run") + if err != nil { + return nil, AuthError{ + http.StatusServiceUnavailable, + fmt.Sprintf("cannot fetch workflow run's pull request: %s", err), + } + } + defer response.Body.Close() + + if response.StatusCode == http.StatusForbidden { + return nil, AuthError{http.StatusForbidden, "not an automatic actions token"} + } else if response.StatusCode == http.StatusUnauthorized { + return nil, AuthError{http.StatusUnauthorized, "malformed token"} + } else if response.StatusCode == http.StatusNotFound { + return nil, AuthError{http.StatusInternalServerError, "endpoint not available"} + } else if response.StatusCode != http.StatusOK { + return nil, AuthError{ + http.StatusServiceUnavailable, + fmt.Sprintf( + "cannot fetch workflow run's pull request: GET %s returned %s", + request.URL, + response.Status, + ), + } + } + + var runInfo struct { + Event string `json:"event"` + EventPayload string `json:"event_payload"` + TriggerUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + } `json:"trigger_user"` + } + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&runInfo); err != nil { + return nil, errors.Join(AuthError{ + http.StatusServiceUnavailable, + fmt.Sprintf( + "cannot fetch workflow run's pull request: GET %s returned malformed JSON", + request.URL, + ), + }, err) + } + origin := request.URL.Hostname() + actionRun := &ForgeActionRun{ + TriggerUser: &ForgeUser{ + Origin: &origin, + Id: &runInfo.TriggerUser.ID, + Name: &runInfo.TriggerUser.Username, + }, + } + + if runInfo.Event == "pull_request" { + var eventInfo struct { + Number int64 `json:"number"` + Repository struct { + ID int64 `json:"id"` + Name string `json:"name"` + Owner struct { + ID int64 `json:"id"` + Username string `json:"username"` + } + } + } + decoder = json.NewDecoder(strings.NewReader(runInfo.EventPayload)) + if err := decoder.Decode(&eventInfo); err != nil { + return nil, errors.Join(AuthError{ + http.StatusServiceUnavailable, + fmt.Sprintf( + "cannot fetch workflow run's pull request: GET %s returned malformed JSON "+ + "in event payload", + request.URL, + ), + }, err) + } + actionRun.PullRequest = &ForgePullRequest{ + OwnerID: eventInfo.Repository.Owner.ID, + OwnerName: eventInfo.Repository.Owner.Username, + RepositoryID: eventInfo.Repository.ID, + RepositoryName: eventInfo.Repository.Name, + Number: eventInfo.Number, + } + } else { + // we aren't decoding the other events + } + + return actionRun, nil +} diff --git a/src/schema.pb.go b/src/schema.pb.go index 8d5d470..788cd28 100644 --- a/src/schema.pb.go +++ b/src/schema.pb.go @@ -930,7 +930,7 @@ type ForgeUser struct { state protoimpl.MessageState `protogen:"open.v1"` Origin *string `protobuf:"bytes,1,opt,name=origin" json:"origin,omitempty"` Id *int64 `protobuf:"varint,2,opt,name=id" json:"id,omitempty"` - Handle *string `protobuf:"bytes,3,opt,name=handle" json:"handle,omitempty"` + Name *string `protobuf:"bytes,3,opt,name=name" json:"name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -979,9 +979,9 @@ func (x *ForgeUser) GetId() int64 { return 0 } -func (x *ForgeUser) GetHandle() string { - if x != nil && x.Handle != nil { - return *x.Handle +func (x *ForgeUser) GetName() string { + if x != nil && x.Name != nil { + return *x.Name } return "" } @@ -1057,11 +1057,11 @@ const file_schema_proto_rawDesc = "" + "\n" + "forge_user\x18\x03 \x01(\v2\n" + ".ForgeUserR\tforgeUser\x12\x19\n" + - "\brepo_url\x18\x04 \x01(\tR\arepoUrl\"K\n" + + "\brepo_url\x18\x04 \x01(\tR\arepoUrl\"G\n" + "\tForgeUser\x12\x16\n" + "\x06origin\x18\x01 \x01(\tR\x06origin\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x03R\x02id\x12\x16\n" + - "\x06handle\x18\x03 \x01(\tR\x06handle*V\n" + + "\x02id\x18\x02 \x01(\x03R\x02id\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name*V\n" + "\x04Type\x12\x10\n" + "\fInvalidEntry\x10\x00\x12\r\n" + "\tDirectory\x10\x01\x12\x0e\n" + diff --git a/src/schema.proto b/src/schema.proto index cbed95f..99fca45 100644 --- a/src/schema.proto +++ b/src/schema.proto @@ -150,5 +150,5 @@ message Principal { message ForgeUser { string origin = 1; int64 id = 2; - string handle = 3; + string name = 3; } diff --git a/src/wildcard.go b/src/wildcard.go index 36398f7..f57d8d2 100644 --- a/src/wildcard.go +++ b/src/wildcard.go @@ -10,6 +10,7 @@ import ( type WildcardPattern struct { Domain []string + PreviewDomain []string CloneURL *fasttemplate.Template IndexRepo *fasttemplate.Template IndexBranch string @@ -22,31 +23,44 @@ func (pattern *WildcardPattern) GetHost() string { return strings.Join(parts, ".") } +type WildcardDomainKind int + +const ( + WildcardDomainPrimary WildcardDomainKind = iota + WildcardDomainPreview + WildcardDomainAny +) + // Returns `subdomain, found` where if `found == true`, `subdomain` contains the part of `host` // corresponding to the * in the domain pattern. -func (pattern *WildcardPattern) Matches(host string) (string, bool) { +func (pattern *WildcardPattern) Matches(host string, kind WildcardDomainKind) (string, bool) { + var suffixParts []string + switch kind { + case WildcardDomainPrimary: + suffixParts = pattern.Domain + case WildcardDomainPreview: + suffixParts = pattern.PreviewDomain + case WildcardDomainAny: + if userName, found := pattern.Matches(host, WildcardDomainPrimary); found { + return userName, found + } else if userName, found := pattern.Matches(host, WildcardDomainPreview); found { + return userName, found + } + return "", false + default: + panic("invalid wildcard domain kind") + } + hostParts := strings.Split(host, ".") - hostLen := len(hostParts) - patternLen := len(pattern.Domain) - - // host must have at least one more part than the pattern domain - if hostLen <= patternLen { + if len(suffixParts) == 0 { return "", false - } - - // break the host parts into and - mid := hostLen - patternLen - prefix := hostParts[:mid] - suffix := hostParts[mid:] - - // check if the suffix matches the domain - if !slices.Equal(suffix, pattern.Domain) { + } else if len(hostParts) != len(suffixParts)+1 { return "", false + } else if !slices.Equal(hostParts[1:], suffixParts) { + return "", false + } else { + return hostParts[0], true } - - // return all the subdomain parts - subdomain := strings.Join(prefix, ".") - return subdomain, true } func (pattern *WildcardPattern) ApplyTemplate(userName string, projectName string) (string, string) { @@ -69,36 +83,48 @@ func (pattern *WildcardPattern) ApplyTemplate(userName string, projectName strin return repoURL, branch } -func TranslateWildcards(configs []WildcardConfig) ([]*WildcardPattern, error) { +func TranslateWildcards(wildcardConfigs []WildcardConfig) ([]*WildcardPattern, error) { var wildcardPatterns []*WildcardPattern - for _, config := range configs { - cloneURLTemplate, err := fasttemplate.NewTemplate(config.CloneURL, "<", ">") + for _, wildcardConfig := range wildcardConfigs { + cloneURLTemplate, err := fasttemplate.NewTemplate(wildcardConfig.CloneURL, "<", ">") if err != nil { return nil, fmt.Errorf("wildcard pattern: clone URL: %w", err) } - var indexRepoBranch string = config.IndexRepoBranch - indexRepoTemplate, err := fasttemplate.NewTemplate(config.IndexRepo, "<", ">") + var indexRepoBranch string = wildcardConfig.IndexRepoBranch + indexRepoTemplate, err := fasttemplate.NewTemplate(wildcardConfig.IndexRepo, "<", ">") if err != nil { return nil, fmt.Errorf("wildcard pattern: index repo: %w", err) } authorization := false - if config.Authorization != "" { - if slices.Contains([]string{"gogs", "gitea", "forgejo"}, config.Authorization) { + if wildcardConfig.Authorization != "" { + if slices.Contains([]string{"gogs", "gitea", "forgejo"}, wildcardConfig.Authorization) { // Currently these are the only supported forges, and the authorization mechanism // is the same for all of them. authorization = true } else { return nil, fmt.Errorf( "wildcard pattern: unknown authorization mechanism: %s", - config.Authorization, + wildcardConfig.Authorization, + ) + } + } + + if !config.Feature("preview") { + wildcardConfig.PreviewDomain = "" + } + if wildcardConfig.PreviewDomain != "" { + if wildcardConfig.Authorization != "forgejo" { + return nil, fmt.Errorf( + "wildcard pattern: previews require Forgejo authorization", ) } } wildcardPatterns = append(wildcardPatterns, &WildcardPattern{ - Domain: strings.Split(config.Domain, "."), + Domain: strings.Split(wildcardConfig.Domain, "."), + PreviewDomain: strings.Split(wildcardConfig.PreviewDomain, "."), CloneURL: cloneURLTemplate, IndexRepo: indexRepoTemplate, IndexBranch: indexRepoBranch,