mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-14 03:01:48 +00:00
Before this commit, a `_git-pages-repository.<host>` TXT record would
allow both forge DNS allowlist authorization, as well as normal DNS
allowlist authorization. This means that a site set up to have its
contents updated by a Forgejo Action could have its contents replaced
by the contents of the repository which contains the Forgejo Action,
which will effectively erase the site in most cases. This is a classic
confused deputy scenario.
To fix this, forge DNS allowlist authorization now uses a distinct
`_git-pages-forge-allowlist.<host>` TXT record, removing ambiguity
that allows this scenario to happen.
The issue was introduced in 27a6de792c
and existed in `main` for about a hour, so it is unlikely anybody
has been impacted by this.
866 lines
24 KiB
Go
866 lines
24 KiB
Go
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
|
|
}
|