Expose site manifest to authorized clients.

As the rules for serving a site get more complex, being able to see
the git-pages' view of the site structure will become increasingly
valuable.

Unauthorized clients are rejected to make enumeration more difficult.
While git-pages isn't designed to serve sensitive data, it is prudent
to recognize that someone somewhere will do it anyway.
This commit is contained in:
Catherine
2025-09-19 16:58:00 +00:00
parent dbfdd5d418
commit e92b48b99f
3 changed files with 113 additions and 21 deletions

View File

@@ -48,10 +48,12 @@ func GetHost(r *http.Request) string {
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 strings.HasPrefix(path, ".") {
return "", AuthError{http.StatusBadRequest, "directory name %s is reserved"}
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"}
return "", AuthError{http.StatusBadRequest,
"directories nested too deep"}
}
if path == "" {
@@ -72,16 +74,19 @@ func authorizeDNSChallenge(r *http.Request) (*Authorization, error) {
authorization := r.Header.Get("Authorization")
if authorization == "" {
return nil, AuthError{http.StatusUnauthorized, "missing Authorization header"}
return nil, AuthError{http.StatusUnauthorized,
"missing Authorization header"}
}
scheme, param, success := strings.Cut(authorization, " ")
if !success {
return nil, AuthError{http.StatusBadRequest, "malformed Authorization header"}
return nil, AuthError{http.StatusBadRequest,
"malformed Authorization header"}
}
if scheme != "Pages" && scheme != "Basic" {
return nil, AuthError{http.StatusBadRequest, "unknown Authorization scheme"}
return nil, AuthError{http.StatusBadRequest,
"unknown Authorization scheme"}
}
// services like GitHub and Gogs cannot send a custom Authorization: header, but supplying
@@ -89,16 +94,19 @@ func authorizeDNSChallenge(r *http.Request) (*Authorization, error) {
if scheme == "Basic" {
basicParam, err := base64.StdEncoding.DecodeString(param)
if err != nil {
return nil, AuthError{http.StatusBadRequest, "malformed Authorization: Basic header"}
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"}
return nil, AuthError{http.StatusBadRequest,
"malformed Authorization: Basic parameter"}
}
if username != "Pages" {
return nil, AuthError{http.StatusUnauthorized, "unexpected Authorization: Basic username"}
return nil, AuthError{http.StatusUnauthorized,
"unexpected Authorization: Basic username"}
}
param = password
@@ -147,7 +155,21 @@ func authorizeDNSAllowlist(r *http.Request) (*Authorization, error) {
return &Authorization{repoURLs}, err
}
func authorizeWildcardMatch(r *http.Request) (*Authorization, error) {
func authorizeWildcardMatchHost(r *http.Request) (*Authorization, error) {
host := GetHost(r)
hostParts := strings.Split(host, ".")
if slices.Equal(hostParts[1:], wildcardPattern.Domain) {
return &Authorization{}, nil
} else {
return nil, AuthError{
http.StatusUnauthorized,
fmt.Sprintf("domain %s does not match wildcard *.%s", host, config.Wildcard.Domain),
}
}
}
func authorizeWildcardMatchSite(r *http.Request) (*Authorization, error) {
host := GetHost(r)
hostParts := strings.Split(host, ".")
@@ -183,10 +205,42 @@ func authorizeWildcardMatch(r *http.Request) (*Authorization, error) {
}
}
func AuthorizeMetadata(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
}
auth, err = authorizeWildcardMatchHost(r)
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",
config.Wildcard.Domain)
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 AuthorizeRequest(r *http.Request) (*Authorization, error) {
func AuthorizeUpdate(r *http.Request) (*Authorization, error) {
causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}
if InsecureMode() {
@@ -220,7 +274,7 @@ func AuthorizeRequest(r *http.Request) (*Authorization, error) {
// Wildcard match is only available for webhooks, not the REST API.
if r.Method == http.MethodPost {
auth, err = authorizeWildcardMatch(r)
auth, err = authorizeWildcardMatchSite(r)
if err != nil && IsUnauthorized(err) {
causes = append(causes, err)
} else if err != nil { // bad request

View File

@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
@@ -60,6 +61,17 @@ func DecodeManifest(data []byte) (*Manifest, error) {
return &manifest, err
}
func ManifestDebugJSON(manifest *Manifest) []byte {
result, err := protojson.MarshalOptions{
Multiline: true,
EmitDefaultValues: true,
}.Marshal(manifest)
if err != nil {
panic(err)
}
return result
}
const maxSymlinkLevels int = 128
var symlinkLoop = errors.New("symbolic link loop")

View File

@@ -26,7 +26,7 @@ func makeWebRoot(host string, projectName string) string {
func getPage(w http.ResponseWriter, r *http.Request) error {
var err error
var urlPath string
var sitePath string
var manifest *Manifest
host := GetHost(r)
@@ -35,11 +35,11 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Max-Age", "86400")
urlPath, _ = strings.CutPrefix(r.URL.Path, "/")
if projectName, projectPath, found := strings.Cut(urlPath, "/"); found {
sitePath, _ = strings.CutPrefix(r.URL.Path, "/")
if projectName, projectPath, found := strings.Cut(sitePath, "/"); found {
projectManifest, err := backend.GetManifest(makeWebRoot(host, projectName))
if err == nil {
urlPath, manifest = projectPath, projectManifest
sitePath, manifest = projectPath, projectManifest
}
}
if manifest == nil {
@@ -51,7 +51,33 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
}
}
entryPath := urlPath
if sitePath == ".git-pages" {
// metadata directory name shouldn't be served even if present in site manifest
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "not found\n")
return nil
}
if metadataPath, found := strings.CutPrefix(sitePath, ".git-pages/"); found {
// metadata requests require authorization to avoid making pushes from private
// repositories enumerable
_, err := AuthorizeMetadata(r)
if err != nil {
return err
}
switch metadataPath {
case "manifest.json":
w.Header().Add("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write(ManifestDebugJSON(manifest))
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "not found\n")
}
return nil
}
entryPath := sitePath
entry := (*Entry)(nil)
is404 := false
reader := io.ReadSeeker(nil)
@@ -129,7 +155,7 @@ func getPage(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
// http.ServeContent handles content type and caching
http.ServeContent(w, r, urlPath, mtime, reader)
http.ServeContent(w, r, sitePath, mtime, reader)
}
return nil
}
@@ -155,7 +181,7 @@ func getProjectName(w http.ResponseWriter, r *http.Request) (string, error) {
}
func putPage(w http.ResponseWriter, r *http.Request) error {
auth, err := AuthorizeRequest(r)
auth, err := AuthorizeUpdate(r)
if err != nil {
return err
}
@@ -217,7 +243,7 @@ func putPage(w http.ResponseWriter, r *http.Request) error {
}
func deletePage(w http.ResponseWriter, r *http.Request) error {
_, err := AuthorizeRequest(r)
_, err := AuthorizeUpdate(r)
if err != nil {
return err
}
@@ -242,7 +268,7 @@ func deletePage(w http.ResponseWriter, r *http.Request) error {
}
func postPage(w http.ResponseWriter, r *http.Request) error {
auth, err := AuthorizeRequest(r)
auth, err := AuthorizeUpdate(r)
if err != nil {
return err
}