mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-14 03:01:48 +00:00
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:
78
src/auth.go
78
src/auth.go
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
44
src/pages.go
44
src/pages.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user