Allow using forge authorization with non-wildcard domains.

The new authorization method combines DNS allowlist and existing forge
authorization methods: DNS records are used to determine the allowed
repository URL, and forge authorization is used to check for push
permissions to that URL.
This commit is contained in:
Catherine
2026-04-22 01:45:54 +00:00
parent 2c109a5e1e
commit 27a6de792c
2 changed files with 87 additions and 34 deletions

View File

@@ -120,8 +120,9 @@ The authorization flow for content updates (`PUT`, `PATCH`, `DELETE`, `POST` req
4. **Wildcard Match (content):** 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://<user>.<host>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating each element of `[[wildcard]].index-repos` with `<user>`, and `[[wildcard]]` is the section where the match occurred.
- **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.
5. **Forge Authorization:** 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 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 (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. (This enables publishing a site for a private repository.)
5. **Default Deny:** Otherwise, the request is not authorized.
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 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 (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://<user>.<host>/`, and a TXT record lookup at `_git-pages-repository.<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.
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:

View File

@@ -620,7 +620,7 @@ func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) e
}
// Gogs, Gitea, and Forgejo all support the same API here.
func fetchGogsAuthorizedUser(baseURL *url.URL, authorization string) (*ForgeUser, error) {
func fetchGogsAuthorizedUser(baseURL *url.URL, forgeToken string) (*ForgeUser, error) {
request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{
Path: "/api/v1/user",
}).String(), nil)
@@ -628,7 +628,7 @@ func fetchGogsAuthorizedUser(baseURL *url.URL, authorization string) (*ForgeUser
panic(err) // misconfiguration
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", authorization)
request.Header.Set("Authorization", forgeToken)
httpClient := http.Client{Timeout: 5 * time.Second}
response, err := httpClient.Do(request)
@@ -674,9 +674,34 @@ func fetchGogsAuthorizedUser(baseURL *url.URL, authorization string) (*ForgeUser
}, nil
}
func authorizeForgeWithToken(r *http.Request) (*Authorization, error) {
authorization := r.Header.Get("Forge-Authorization")
if authorization == "" {
// 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"}
}
@@ -692,42 +717,56 @@ func authorizeForgeWithToken(r *http.Request) (*Authorization, error) {
var errs []error
for _, pattern := range wildcards {
if !pattern.Authorization {
continue
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"})
}
if userName, found := pattern.Matches(host); found {
repoURL, branch := pattern.ApplyTemplate(userName, projectName)
parsedRepoURL, err := url.Parse(repoURL)
if err != nil {
panic(err) // misconfiguration
}
errs = append([]error{
AuthError{http.StatusUnauthorized, "not authorized by forge (wildcard)"},
}, errs...)
return nil, joinErrors(errs...)
}
if err = checkGogsRepositoryPushPermission(parsedRepoURL, authorization); err != nil {
errs = append(errs, err)
continue
}
// Validates a provided forge token against a repository URL extracted from the DNS allowlist
// records of the target domain (`_git-pages-repository.*`).
func authorizeForgeDNSAllowlist(r *http.Request) (*Authorization, error) {
forgeToken := r.Header.Get("Forge-Authorization")
if forgeToken == "" {
return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"}
}
authorizedUser, err := fetchGogsAuthorizedUser(parsedRepoURL, authorization)
var errs []error
if dnsAuth, err := authorizeDNSAllowlist(r); 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)
continue
} else {
// There is both DNS authorization and forge authorization.
return auth, nil
}
return &Authorization{
// This will actually be ignored by the callers of AuthorizeUpdateFromArchive and
// AuthorizeDeletion, but we return this information as it makes sense to do
// contextually here.
repoURLs: []string{repoURL},
branch: branch,
forgeUser: authorizedUser,
}, nil
}
}
errs = append([]error{
AuthError{http.StatusUnauthorized, "not authorized by forge"},
AuthError{http.StatusUnauthorized, "not authorized by forge (DNS allowlist)"},
}, errs...)
return nil, joinErrors(errs...)
}
@@ -756,13 +795,26 @@ func authorizeDNSChallengeOrForgeWithToken(r *http.Request) (*Authorization, err
}
// Token authorization allows updating a site on a wildcard domain from an archive.
auth, err = authorizeForgeWithToken(r)
// 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 token: allow\n")
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
}