Files
git-pages/src/auth.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...)
}