diff --git a/src/auth.go b/src/auth.go index ef3c4a7..3f5add7 100644 --- a/src/auth.go +++ b/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 diff --git a/src/manifest.go b/src/manifest.go index bc851de..0a3e294 100644 --- a/src/manifest.go +++ b/src/manifest.go @@ -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") diff --git a/src/pages.go b/src/pages.go index d4bf0c1..8eb5cf7 100644 --- a/src/pages.go +++ b/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 }