mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-17 04:31:38 +00:00
370 lines
9.8 KiB
Go
370 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
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 InsecureMode() bool {
|
|
return os.Getenv("INSECURE") == "very"
|
|
}
|
|
|
|
func GetHost(r *http.Request) (string, error) {
|
|
// FIXME: handle IDNA
|
|
host, _, err := net.SplitHostPort(r.Host)
|
|
if err != nil {
|
|
// dirty but the go stdlib doesn't have a "split port if present" function
|
|
host = r.Host
|
|
}
|
|
if strings.HasPrefix(host, ".") {
|
|
return "", AuthError{http.StatusBadRequest,
|
|
fmt.Sprintf("host name %q is reserved", host)}
|
|
}
|
|
return host, nil
|
|
}
|
|
|
|
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 path == ".index" || strings.HasPrefix(path, ".index/") {
|
|
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
|
|
}
|
|
|
|
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{}, nil
|
|
}
|
|
|
|
func authorizeDNSAllowlist(r *http.Request) (*Authorization, error) {
|
|
host, err := GetHost(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allowlistHostname := fmt.Sprintf("_git-pages-repository.%s", host)
|
|
repoURLs, err := net.LookupTXT(allowlistHostname)
|
|
if err != nil {
|
|
return nil, AuthError{http.StatusUnauthorized,
|
|
fmt.Sprintf("failed to look up DNS repository allowlist: %s TXT", allowlistHostname)}
|
|
}
|
|
|
|
for _, repoURL := range repoURLs {
|
|
if parsedURL, err := url.Parse(repoURL); err != nil {
|
|
return nil, AuthError{http.StatusBadRequest,
|
|
fmt.Sprintf("failed to parse URL: %s TXT %q", allowlistHostname, repoURL)}
|
|
} else if !parsedURL.IsAbs() {
|
|
return nil, AuthError{http.StatusBadRequest,
|
|
fmt.Sprintf("repository URL is not absolute: %s TXT %q", allowlistHostname, repoURL)}
|
|
}
|
|
}
|
|
|
|
return &Authorization{repoURLs}, err
|
|
}
|
|
|
|
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{}, nil
|
|
} else {
|
|
return nil, AuthError{
|
|
http.StatusUnauthorized,
|
|
fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()),
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
var repoURLs []string
|
|
repoURLTemplate := pattern.CloneURL
|
|
if projectName == ".index" {
|
|
for _, indexRepoTemplate := range pattern.IndexRepos {
|
|
indexRepo := indexRepoTemplate.ExecuteString(map[string]any{"user": userName})
|
|
repoURLs = append(repoURLs, repoURLTemplate.ExecuteString(map[string]interface{}{
|
|
"user": userName,
|
|
"project": indexRepo,
|
|
}))
|
|
}
|
|
} else {
|
|
repoURLs = append(repoURLs, repoURLTemplate.ExecuteString(map[string]interface{}{
|
|
"user": userName,
|
|
"project": projectName,
|
|
}))
|
|
}
|
|
return &Authorization{repoURLs}, nil
|
|
} else {
|
|
return nil, AuthError{
|
|
http.StatusUnauthorized,
|
|
fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()),
|
|
}
|
|
}
|
|
}
|
|
|
|
func AuthorizeMetadataRetrieval(r *http.Request) (*Authorization, error) {
|
|
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
|
|
|
|
if InsecureMode() {
|
|
log.Println("auth: INSECURE mode")
|
|
return &Authorization{}, nil // for testing only
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
for _, pattern := range wildcardPatterns {
|
|
auth, err = authorizeWildcardMatchHost(r, pattern)
|
|
if err != nil && IsUnauthorized(err) {
|
|
causes = append(causes, err)
|
|
} else if err != nil { // bad request
|
|
return nil, err
|
|
} else {
|
|
log.Printf("auth: wildcard %s\n", pattern.GetHost())
|
|
return auth, nil
|
|
}
|
|
}
|
|
|
|
return nil, errors.Join(causes...)
|
|
}
|
|
|
|
// Returns `repoURLs, err` where if `err == nil` then the request is authorized to clone from
|
|
// any repository URL included in `repoURLs` (by case-insensitive comparison), or any URL at all
|
|
// if `repoURLs == nil`.
|
|
func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) {
|
|
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
|
|
|
|
if InsecureMode() {
|
|
log.Println("auth: INSECURE mode: allow *")
|
|
return &Authorization{}, nil // for testing only
|
|
}
|
|
|
|
// 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: 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)
|
|
if err != nil && IsUnauthorized(err) {
|
|
causes = append(causes, err)
|
|
} else if err != nil { // bad request
|
|
return nil, err
|
|
} else {
|
|
log.Printf("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 wildcardPatterns {
|
|
auth, err = authorizeWildcardMatchSite(r, pattern)
|
|
if err != nil && IsUnauthorized(err) {
|
|
causes = append(causes, err)
|
|
} else if err != nil { // bad request
|
|
return nil, err
|
|
} else {
|
|
log.Printf("auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs)
|
|
return auth, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, errors.Join(causes...)
|
|
}
|
|
|
|
func AuthorizeRepository(repoURL string, auth *Authorization) error {
|
|
if auth.repoURLs == nil {
|
|
return nil // any
|
|
}
|
|
|
|
allowed := false
|
|
for _, allowedRepoURL := range auth.repoURLs {
|
|
if strings.EqualFold(repoURL, allowedRepoURL) {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if allowed {
|
|
return nil
|
|
} else {
|
|
return AuthError{
|
|
http.StatusUnauthorized,
|
|
fmt.Sprintf("clone URL not in allowlist %v", auth.repoURLs),
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 == "pages" {
|
|
return nil
|
|
} else {
|
|
return AuthError{
|
|
http.StatusUnauthorized,
|
|
fmt.Sprintf("branch %s: password authorization required", branch),
|
|
}
|
|
}
|
|
}
|
|
|
|
func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
|
|
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
|
|
|
|
if InsecureMode() {
|
|
log.Println("auth: INSECURE mode")
|
|
return &Authorization{}, nil // for testing only
|
|
}
|
|
|
|
// 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, errors.Join(causes...)
|
|
}
|