Implement pull request preview authorization.

Requires `feature = ["preview"]`.
This commit is contained in:
Catherine
2026-05-25 23:59:39 +00:00
parent 24a54f6fce
commit 3efb332351
8 changed files with 235 additions and 53 deletions
+7 -5
View File
@@ -121,17 +121,19 @@ The authorization flow for content updates (`PUT`, `PATCH`, `DELETE`, `POST` req
- **`Basic` scheme:** Request includes an `Authorization: Basic <basic>` header, where `<basic>` is equal to `Base64("Pages:<token>")`. (Useful for non-Forgejo forges.)
3. **DNS Allowlist:** If the method is `PUT` or `POST`, and the request URL is `scheme://<user>.<host>/`, and a TXT record lookup at `_git-pages-repository.<host>` 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://<user>.<host>/`: a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating `[[wildcard]].index-repo` with `<user>`, 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://<user>.<host>/<project>/`: a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, and `[[wildcard]]` is the section where the match occurred; and a *matching* branch is `pages`.
- **Index repository:** If the request URL is `scheme://<user>.<host>/`: a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating `[[wildcard]].index-repo` with `<user>`; and a *matching* branch is specified by `[[wildcard]].index-repo-branch`.
- **Project repository:** If the request URL is `scheme://<user>.<host>/<project>/`: a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`; 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://<host>/`, and a TXT record lookup at `_git-pages-forge-allowlist.<host>` 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://<user>.<host>/<project>@<number>/`: a *matching* pull request has a number `<number>` and belongs to the repository with a clone URL computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`.
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://<host>/`, and a TXT record lookup at `_git-pages-forge-allowlist.<host>` 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.
+1 -1
View File
@@ -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() != "" {
+48 -5
View File
@@ -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 {
+1
View File
@@ -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"`
+116 -6
View File
@@ -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
}
+7 -7
View File
@@ -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" +
+1 -1
View File
@@ -150,5 +150,5 @@ message Principal {
message ForgeUser {
string origin = 1;
int64 id = 2;
string handle = 3;
string name = 3;
}
+54 -28
View File
@@ -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 <subdomain parts> and <domain parts>
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,