From 1dedf9bba0bf909f9e275565604ec8f2adb5a86e Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 3 Nov 2023 04:38:08 +0800 Subject: Fix http protocol auth (#27875) (#27878) backport #27875 --- routers/web/githttp.go | 43 +++ routers/web/repo/githttp.go | 613 +++++++++++++++++++++++++++++++++++++++ routers/web/repo/githttp_test.go | 42 +++ routers/web/repo/http.go | 613 --------------------------------------- routers/web/repo/http_test.go | 42 --- routers/web/web.go | 17 +- 6 files changed, 701 insertions(+), 669 deletions(-) create mode 100644 routers/web/githttp.go create mode 100644 routers/web/repo/githttp.go create mode 100644 routers/web/repo/githttp_test.go delete mode 100644 routers/web/repo/http.go delete mode 100644 routers/web/repo/http_test.go diff --git a/routers/web/githttp.go b/routers/web/githttp.go new file mode 100644 index 0000000000..b2fb5b472f --- /dev/null +++ b/routers/web/githttp.go @@ -0,0 +1,43 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/repo" + context_service "code.gitea.io/gitea/services/context" +) + +func requireSignIn(ctx *context.Context) { + if !setting.Service.RequireSignInView { + return + } + + // rely on the results of Contexter + if !ctx.IsSigned { + // TODO: support digit auth - which would be Authorization header with digit + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`) + ctx.Error(http.StatusUnauthorized) + } +} + +func gitHTTPRouters(m *web.Route) { + m.Group("", func() { + m.PostOptions("/git-upload-pack", repo.ServiceUploadPack) + m.PostOptions("/git-receive-pack", repo.ServiceReceivePack) + m.GetOptions("/info/refs", repo.GetInfoRefs) + m.GetOptions("/HEAD", repo.GetTextFile("HEAD")) + m.GetOptions("/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) + m.GetOptions("/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) + m.GetOptions("/objects/info/packs", repo.GetInfoPacks) + m.GetOptions("/objects/info/{file:[^/]*}", repo.GetTextFile("")) + m.GetOptions("/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38}}", repo.GetLooseObject) + m.GetOptions("/objects/pack/pack-{file:[0-9a-f]{40}}.pack", repo.GetPackFile) + m.GetOptions("/objects/pack/pack-{file:[0-9a-f]{40}}.idx", repo.GetIdxFile) + }, ignSignInAndCsrf, requireSignIn, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context_service.UserAssignmentWeb()) +} diff --git a/routers/web/repo/githttp.go b/routers/web/repo/githttp.go new file mode 100644 index 0000000000..f4e9ac86a1 --- /dev/null +++ b/routers/web/repo/githttp.go @@ -0,0 +1,613 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "compress/gzip" + gocontext "context" + "fmt" + "net/http" + "os" + "path" + "regexp" + "strconv" + "strings" + "sync" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/go-chi/cors" +) + +func HTTPGitEnabledHandler(ctx *context.Context) { + if setting.Repository.DisableHTTPGit { + ctx.Resp.WriteHeader(http.StatusForbidden) + _, _ = ctx.Resp.Write([]byte("Interacting with repositories by HTTP protocol is not allowed")) + } +} + +func CorsHandler() func(next http.Handler) http.Handler { + if setting.Repository.AccessControlAllowOrigin != "" { + return cors.Handler(cors.Options{ + AllowedOrigins: []string{setting.Repository.AccessControlAllowOrigin}, + AllowedHeaders: []string{"Content-Type", "Authorization", "User-Agent"}, + }) + } + return func(next http.Handler) http.Handler { + return next + } +} + +// httpBase implementation git smart HTTP protocol +func httpBase(ctx *context.Context) (h *serviceHandler) { + username := ctx.Params(":username") + reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git") + + if ctx.FormString("go-get") == "1" { + context.EarlyResponseForGoGetMeta(ctx) + return + } + + var isPull, receivePack bool + service := ctx.FormString("service") + if service == "git-receive-pack" || + strings.HasSuffix(ctx.Req.URL.Path, "git-receive-pack") { + isPull = false + receivePack = true + } else if service == "git-upload-pack" || + strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") { + isPull = true + } else if service == "git-upload-archive" || + strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") { + isPull = true + } else { + isPull = ctx.Req.Method == "GET" + } + + var accessMode perm.AccessMode + if isPull { + accessMode = perm.AccessModeRead + } else { + accessMode = perm.AccessModeWrite + } + + isWiki := false + unitType := unit.TypeCode + var wikiRepoName string + if strings.HasSuffix(reponame, ".wiki") { + isWiki = true + unitType = unit.TypeWiki + wikiRepoName = reponame + reponame = reponame[:len(reponame)-5] + } + + owner := ctx.ContextUser + if !owner.IsOrganization() && !owner.IsActive { + ctx.PlainText(http.StatusForbidden, "Repository cannot be accessed. You cannot push or open issues/pull-requests.") + return + } + + repoExist := true + repo, err := repo_model.GetRepositoryByName(owner.ID, reponame) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + if redirectRepoID, err := repo_model.LookupRedirect(owner.ID, reponame); err == nil { + context.RedirectToRepo(ctx.Base, redirectRepoID) + return + } + repoExist = false + } else { + ctx.ServerError("GetRepositoryByName", err) + return + } + } + + // Don't allow pushing if the repo is archived + if repoExist && repo.IsArchived && !isPull { + ctx.PlainText(http.StatusForbidden, "This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.") + return + } + + // Only public pull don't need auth. + isPublicPull := repoExist && !repo.IsPrivate && isPull + var ( + askAuth = !isPublicPull || setting.Service.RequireSignInView + environ []string + ) + + // don't allow anonymous pulls if organization is not public + if isPublicPull { + if err := repo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return + } + + askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic) + } + + // check access + if askAuth { + // rely on the results of Contexter + if !ctx.IsSigned { + // TODO: support digit auth - which would be Authorization header with digit + ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"") + ctx.Error(http.StatusUnauthorized) + return + } + + context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) + if ctx.Written() { + return + } + + if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true && ctx.Data["IsActionsToken"] != true { + _, err = auth_model.GetTwoFactorByUID(ctx.Doer.ID) + if err == nil { + // TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented + ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page") + return + } else if !auth_model.IsErrTwoFactorNotEnrolled(err) { + ctx.ServerError("IsErrTwoFactorNotEnrolled", err) + return + } + } + + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + ctx.PlainText(http.StatusForbidden, "Your account is disabled.") + return + } + + environ = []string{ + repo_module.EnvRepoUsername + "=" + username, + repo_module.EnvRepoName + "=" + reponame, + repo_module.EnvPusherName + "=" + ctx.Doer.Name, + repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID), + repo_module.EnvAppURL + "=" + setting.AppURL, + } + + if repoExist { + // Because of special ref "refs/for" .. , need delay write permission check + if git.SupportProcReceive { + accessMode = perm.AccessModeRead + } + + if ctx.Data["IsActionsToken"] == true { + taskID := ctx.Data["ActionsTaskID"].(int64) + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + ctx.ServerError("GetTaskByID", err) + return + } + if task.RepoID != repo.ID { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return + } + + if task.IsForkPullRequest { + if accessMode > perm.AccessModeRead { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return + } + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeRead)) + } else { + if accessMode > perm.AccessModeWrite { + ctx.PlainText(http.StatusForbidden, "User permission denied") + return + } + environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeWrite)) + } + } else { + p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + + if !p.CanAccess(accessMode, unitType) { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return + } + } + + if !isPull && repo.IsMirror { + ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") + return + } + } + + if !ctx.Doer.KeepEmailPrivate { + environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email) + } + + if isWiki { + environ = append(environ, repo_module.EnvRepoIsWiki+"=true") + } else { + environ = append(environ, repo_module.EnvRepoIsWiki+"=false") + } + } + + if !repoExist { + if !receivePack { + ctx.PlainText(http.StatusNotFound, "Repository not found") + return + } + + if isWiki { // you cannot send wiki operation before create the repository + ctx.PlainText(http.StatusNotFound, "Repository not found") + return + } + + if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { + ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for organizations.") + return + } + if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser { + ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for users.") + return + } + + // Return dummy payload if GET receive-pack + if ctx.Req.Method == http.MethodGet { + dummyInfoRefs(ctx) + return + } + + repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) + if err != nil { + log.Error("pushCreateRepo: %v", err) + ctx.Status(http.StatusNotFound) + return + } + } + + if isWiki { + // Ensure the wiki is enabled before we allow access to it + if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { + if repo_model.IsErrUnitTypeNotExist(err) { + ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") + return + } + log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err) + ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) + return + } + } + + environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) + + w := ctx.Resp + r := ctx.Req + cfg := &serviceConfig{ + UploadPack: true, + ReceivePack: true, + Env: environ, + } + + r.URL.Path = strings.ToLower(r.URL.Path) // blue: In case some repo name has upper case name + + dir := repo_model.RepoPath(username, reponame) + if isWiki { + dir = repo_model.RepoPath(username, wikiRepoName) + } + + return &serviceHandler{cfg, w, r, dir, cfg.Env} +} + +var ( + infoRefsCache []byte + infoRefsOnce sync.Once +) + +func dummyInfoRefs(ctx *context.Context) { + infoRefsOnce.Do(func() { + tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-info-refs-cache") + if err != nil { + log.Error("Failed to create temp dir for git-receive-pack cache: %v", err) + return + } + + defer func() { + if err := util.RemoveAll(tmpDir); err != nil { + log.Error("RemoveAll: %v", err) + } + }() + + if err := git.InitRepository(ctx, tmpDir, true); err != nil { + log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) + return + } + + refs, _, err := git.NewCommand(ctx, "receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Dir: tmpDir}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + log.Debug("populating infoRefsCache: \n%s", string(refs)) + infoRefsCache = refs + }) + + ctx.RespHeader().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + ctx.RespHeader().Set("Pragma", "no-cache") + ctx.RespHeader().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + ctx.RespHeader().Set("Content-Type", "application/x-git-receive-pack-advertisement") + _, _ = ctx.Write(packetWrite("# service=git-receive-pack\n")) + _, _ = ctx.Write([]byte("0000")) + _, _ = ctx.Write(infoRefsCache) +} + +type serviceConfig struct { + UploadPack bool + ReceivePack bool + Env []string +} + +type serviceHandler struct { + cfg *serviceConfig + w http.ResponseWriter + r *http.Request + dir string + environ []string +} + +func (h *serviceHandler) setHeaderNoCache() { + h.w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") + h.w.Header().Set("Pragma", "no-cache") + h.w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") +} + +func (h *serviceHandler) setHeaderCacheForever() { + now := time.Now().Unix() + expires := now + 31536000 + h.w.Header().Set("Date", fmt.Sprintf("%d", now)) + h.w.Header().Set("Expires", fmt.Sprintf("%d", expires)) + h.w.Header().Set("Cache-Control", "public, max-age=31536000") +} + +func containsParentDirectorySeparator(v string) bool { + if !strings.Contains(v, "..") { + return false + } + for _, ent := range strings.FieldsFunc(v, isSlashRune) { + if ent == ".." { + return true + } + } + return false +} + +func isSlashRune(r rune) bool { return r == '/' || r == '\\' } + +func (h *serviceHandler) sendFile(contentType, file string) { + if containsParentDirectorySeparator(file) { + log.Error("request file path contains invalid path: %v", file) + h.w.WriteHeader(http.StatusBadRequest) + return + } + reqFile := path.Join(h.dir, file) + + fi, err := os.Stat(reqFile) + if os.IsNotExist(err) { + h.w.WriteHeader(http.StatusNotFound) + return + } + + h.w.Header().Set("Content-Type", contentType) + h.w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + h.w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) + http.ServeFile(h.w, h.r, reqFile) +} + +// one or more key=value pairs separated by colons +var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) + +func prepareGitCmdWithAllowedService(service string, h *serviceHandler) (*git.Command, error) { + if service == "receive-pack" && h.cfg.ReceivePack { + return git.NewCommand(h.r.Context(), "receive-pack"), nil + } + if service == "upload-pack" && h.cfg.UploadPack { + return git.NewCommand(h.r.Context(), "upload-pack"), nil + } + + return nil, fmt.Errorf("service %q is not allowed", service) +} + +func serviceRPC(h *serviceHandler, service string) { + defer func() { + if err := h.r.Body.Close(); err != nil { + log.Error("serviceRPC: Close: %v", err) + } + }() + + expectedContentType := fmt.Sprintf("application/x-git-%s-request", service) + if h.r.Header.Get("Content-Type") != expectedContentType { + log.Error("Content-Type (%q) doesn't match expected: %q", h.r.Header.Get("Content-Type"), expectedContentType) + h.w.WriteHeader(http.StatusUnauthorized) + return + } + + cmd, err := prepareGitCmdWithAllowedService(service, h) + if err != nil { + log.Error("Failed to prepareGitCmdWithService: %v", err) + h.w.WriteHeader(http.StatusUnauthorized) + return + } + + h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) + + reqBody := h.r.Body + + // Handle GZIP. + if h.r.Header.Get("Content-Encoding") == "gzip" { + reqBody, err = gzip.NewReader(reqBody) + if err != nil { + log.Error("Fail to create gzip reader: %v", err) + h.w.WriteHeader(http.StatusInternalServerError) + return + } + } + + // set this for allow pre-receive and post-receive execute + h.environ = append(h.environ, "SSH_ORIGINAL_COMMAND="+service) + + if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + + var stderr bytes.Buffer + cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.dir) + cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.dir)) + if err := cmd.Run(&git.RunOpts{ + Dir: h.dir, + Env: append(os.Environ(), h.environ...), + Stdout: h.w, + Stdin: reqBody, + Stderr: &stderr, + UseContextTimeout: true, + }); err != nil { + if err.Error() != "signal: killed" { + log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.dir, err, stderr.String()) + } + return + } +} + +// ServiceUploadPack implements Git Smart HTTP protocol +func ServiceUploadPack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(h, "upload-pack") + } +} + +// ServiceReceivePack implements Git Smart HTTP protocol +func ServiceReceivePack(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + serviceRPC(h, "receive-pack") + } +} + +func getServiceType(r *http.Request) string { + serviceType := r.FormValue("service") + if !strings.HasPrefix(serviceType, "git-") { + return "" + } + return strings.TrimPrefix(serviceType, "git-") +} + +func updateServerInfo(ctx gocontext.Context, dir string) []byte { + out, _, err := git.NewCommand(ctx, "update-server-info").RunStdBytes(&git.RunOpts{Dir: dir}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(out))) + } + return out +} + +func packetWrite(str string) []byte { + s := strconv.FormatInt(int64(len(str)+4), 16) + if len(s)%4 != 0 { + s = strings.Repeat("0", 4-len(s)%4) + s + } + return []byte(s + str) +} + +// GetInfoRefs implements Git dumb HTTP +func GetInfoRefs(ctx *context.Context) { + h := httpBase(ctx) + if h == nil { + return + } + h.setHeaderNoCache() + service := getServiceType(h.r) + cmd, err := prepareGitCmdWithAllowedService(service, h) + if err == nil { + if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { + h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) + } + h.environ = append(os.Environ(), h.environ...) + + refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.dir}) + if err != nil { + log.Error(fmt.Sprintf("%v - %s", err, string(refs))) + } + + h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) + h.w.WriteHeader(http.StatusOK) + _, _ = h.w.Write(packetWrite("# service=git-" + service + "\n")) + _, _ = h.w.Write([]byte("0000")) + _, _ = h.w.Write(refs) + } else { + updateServerInfo(ctx, h.dir) + h.sendFile("text/plain; charset=utf-8", "info/refs") + } +} + +// GetTextFile implements Git dumb HTTP +func GetTextFile(p string) func(*context.Context) { + return func(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderNoCache() + file := ctx.Params("file") + if file != "" { + h.sendFile("text/plain", "objects/info/"+file) + } else { + h.sendFile("text/plain", p) + } + } + } +} + +// GetInfoPacks implements Git dumb HTTP +func GetInfoPacks(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("text/plain; charset=utf-8", "objects/info/packs") + } +} + +// GetLooseObject implements Git dumb HTTP +func GetLooseObject(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("application/x-git-loose-object", fmt.Sprintf("objects/%s/%s", + ctx.Params("head"), ctx.Params("hash"))) + } +} + +// GetPackFile implements Git dumb HTTP +func GetPackFile(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack") + } +} + +// GetIdxFile implements Git dumb HTTP +func GetIdxFile(ctx *context.Context) { + h := httpBase(ctx) + if h != nil { + h.setHeaderCacheForever() + h.sendFile("application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") + } +} diff --git a/routers/web/repo/githttp_test.go b/routers/web/repo/githttp_test.go new file mode 100644 index 0000000000..5ba8de3d63 --- /dev/null +++ b/routers/web/repo/githttp_test.go @@ -0,0 +1,42 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainsParentDirectorySeparator(t *testing.T) { + tests := []struct { + v string + b bool + }{ + { + v: `user2/repo1/info/refs`, + b: false, + }, + { + v: `user2/repo1/HEAD`, + b: false, + }, + { + v: `user2/repo1/some.../strange_file...mp3`, + b: false, + }, + { + v: `user2/repo1/../../custom/conf/app.ini`, + b: true, + }, + { + v: `user2/repo1/objects/info/..\..\..\..\custom\conf\app.ini`, + b: true, + }, + } + + for i := range tests { + assert.EqualValues(t, tests[i].b, containsParentDirectorySeparator(tests[i].v)) + } +} diff --git a/routers/web/repo/http.go b/routers/web/repo/http.go deleted file mode 100644 index f4e9ac86a1..0000000000 --- a/routers/web/repo/http.go +++ /dev/null @@ -1,613 +0,0 @@ -// Copyright 2014 The Gogs Authors. All rights reserved. -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repo - -import ( - "bytes" - "compress/gzip" - gocontext "context" - "fmt" - "net/http" - "os" - "path" - "regexp" - "strconv" - "strings" - "sync" - "time" - - actions_model "code.gitea.io/gitea/models/actions" - auth_model "code.gitea.io/gitea/models/auth" - "code.gitea.io/gitea/models/perm" - access_model "code.gitea.io/gitea/models/perm/access" - repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unit" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/log" - repo_module "code.gitea.io/gitea/modules/repository" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/util" - repo_service "code.gitea.io/gitea/services/repository" - - "github.com/go-chi/cors" -) - -func HTTPGitEnabledHandler(ctx *context.Context) { - if setting.Repository.DisableHTTPGit { - ctx.Resp.WriteHeader(http.StatusForbidden) - _, _ = ctx.Resp.Write([]byte("Interacting with repositories by HTTP protocol is not allowed")) - } -} - -func CorsHandler() func(next http.Handler) http.Handler { - if setting.Repository.AccessControlAllowOrigin != "" { - return cors.Handler(cors.Options{ - AllowedOrigins: []string{setting.Repository.AccessControlAllowOrigin}, - AllowedHeaders: []string{"Content-Type", "Authorization", "User-Agent"}, - }) - } - return func(next http.Handler) http.Handler { - return next - } -} - -// httpBase implementation git smart HTTP protocol -func httpBase(ctx *context.Context) (h *serviceHandler) { - username := ctx.Params(":username") - reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git") - - if ctx.FormString("go-get") == "1" { - context.EarlyResponseForGoGetMeta(ctx) - return - } - - var isPull, receivePack bool - service := ctx.FormString("service") - if service == "git-receive-pack" || - strings.HasSuffix(ctx.Req.URL.Path, "git-receive-pack") { - isPull = false - receivePack = true - } else if service == "git-upload-pack" || - strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") { - isPull = true - } else if service == "git-upload-archive" || - strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") { - isPull = true - } else { - isPull = ctx.Req.Method == "GET" - } - - var accessMode perm.AccessMode - if isPull { - accessMode = perm.AccessModeRead - } else { - accessMode = perm.AccessModeWrite - } - - isWiki := false - unitType := unit.TypeCode - var wikiRepoName string - if strings.HasSuffix(reponame, ".wiki") { - isWiki = true - unitType = unit.TypeWiki - wikiRepoName = reponame - reponame = reponame[:len(reponame)-5] - } - - owner := ctx.ContextUser - if !owner.IsOrganization() && !owner.IsActive { - ctx.PlainText(http.StatusForbidden, "Repository cannot be accessed. You cannot push or open issues/pull-requests.") - return - } - - repoExist := true - repo, err := repo_model.GetRepositoryByName(owner.ID, reponame) - if err != nil { - if repo_model.IsErrRepoNotExist(err) { - if redirectRepoID, err := repo_model.LookupRedirect(owner.ID, reponame); err == nil { - context.RedirectToRepo(ctx.Base, redirectRepoID) - return - } - repoExist = false - } else { - ctx.ServerError("GetRepositoryByName", err) - return - } - } - - // Don't allow pushing if the repo is archived - if repoExist && repo.IsArchived && !isPull { - ctx.PlainText(http.StatusForbidden, "This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.") - return - } - - // Only public pull don't need auth. - isPublicPull := repoExist && !repo.IsPrivate && isPull - var ( - askAuth = !isPublicPull || setting.Service.RequireSignInView - environ []string - ) - - // don't allow anonymous pulls if organization is not public - if isPublicPull { - if err := repo.LoadOwner(ctx); err != nil { - ctx.ServerError("LoadOwner", err) - return - } - - askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic) - } - - // check access - if askAuth { - // rely on the results of Contexter - if !ctx.IsSigned { - // TODO: support digit auth - which would be Authorization header with digit - ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"") - ctx.Error(http.StatusUnauthorized) - return - } - - context.CheckRepoScopedToken(ctx, repo, auth_model.GetScopeLevelFromAccessMode(accessMode)) - if ctx.Written() { - return - } - - if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true && ctx.Data["IsActionsToken"] != true { - _, err = auth_model.GetTwoFactorByUID(ctx.Doer.ID) - if err == nil { - // TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented - ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page") - return - } else if !auth_model.IsErrTwoFactorNotEnrolled(err) { - ctx.ServerError("IsErrTwoFactorNotEnrolled", err) - return - } - } - - if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { - ctx.PlainText(http.StatusForbidden, "Your account is disabled.") - return - } - - environ = []string{ - repo_module.EnvRepoUsername + "=" + username, - repo_module.EnvRepoName + "=" + reponame, - repo_module.EnvPusherName + "=" + ctx.Doer.Name, - repo_module.EnvPusherID + fmt.Sprintf("=%d", ctx.Doer.ID), - repo_module.EnvAppURL + "=" + setting.AppURL, - } - - if repoExist { - // Because of special ref "refs/for" .. , need delay write permission check - if git.SupportProcReceive { - accessMode = perm.AccessModeRead - } - - if ctx.Data["IsActionsToken"] == true { - taskID := ctx.Data["ActionsTaskID"].(int64) - task, err := actions_model.GetTaskByID(ctx, taskID) - if err != nil { - ctx.ServerError("GetTaskByID", err) - return - } - if task.RepoID != repo.ID { - ctx.PlainText(http.StatusForbidden, "User permission denied") - return - } - - if task.IsForkPullRequest { - if accessMode > perm.AccessModeRead { - ctx.PlainText(http.StatusForbidden, "User permission denied") - return - } - environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeRead)) - } else { - if accessMode > perm.AccessModeWrite { - ctx.PlainText(http.StatusForbidden, "User permission denied") - return - } - environ = append(environ, fmt.Sprintf("%s=%d", repo_module.EnvActionPerm, perm.AccessModeWrite)) - } - } else { - p, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) - if err != nil { - ctx.ServerError("GetUserRepoPermission", err) - return - } - - if !p.CanAccess(accessMode, unitType) { - ctx.PlainText(http.StatusNotFound, "Repository not found") - return - } - } - - if !isPull && repo.IsMirror { - ctx.PlainText(http.StatusForbidden, "mirror repository is read-only") - return - } - } - - if !ctx.Doer.KeepEmailPrivate { - environ = append(environ, repo_module.EnvPusherEmail+"="+ctx.Doer.Email) - } - - if isWiki { - environ = append(environ, repo_module.EnvRepoIsWiki+"=true") - } else { - environ = append(environ, repo_module.EnvRepoIsWiki+"=false") - } - } - - if !repoExist { - if !receivePack { - ctx.PlainText(http.StatusNotFound, "Repository not found") - return - } - - if isWiki { // you cannot send wiki operation before create the repository - ctx.PlainText(http.StatusNotFound, "Repository not found") - return - } - - if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg { - ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for organizations.") - return - } - if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser { - ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for users.") - return - } - - // Return dummy payload if GET receive-pack - if ctx.Req.Method == http.MethodGet { - dummyInfoRefs(ctx) - return - } - - repo, err = repo_service.PushCreateRepo(ctx, ctx.Doer, owner, reponame) - if err != nil { - log.Error("pushCreateRepo: %v", err) - ctx.Status(http.StatusNotFound) - return - } - } - - if isWiki { - // Ensure the wiki is enabled before we allow access to it - if _, err := repo.GetUnit(ctx, unit.TypeWiki); err != nil { - if repo_model.IsErrUnitTypeNotExist(err) { - ctx.PlainText(http.StatusForbidden, "repository wiki is disabled") - return - } - log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err) - ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err) - return - } - } - - environ = append(environ, repo_module.EnvRepoID+fmt.Sprintf("=%d", repo.ID)) - - w := ctx.Resp - r := ctx.Req - cfg := &serviceConfig{ - UploadPack: true, - ReceivePack: true, - Env: environ, - } - - r.URL.Path = strings.ToLower(r.URL.Path) // blue: In case some repo name has upper case name - - dir := repo_model.RepoPath(username, reponame) - if isWiki { - dir = repo_model.RepoPath(username, wikiRepoName) - } - - return &serviceHandler{cfg, w, r, dir, cfg.Env} -} - -var ( - infoRefsCache []byte - infoRefsOnce sync.Once -) - -func dummyInfoRefs(ctx *context.Context) { - infoRefsOnce.Do(func() { - tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-info-refs-cache") - if err != nil { - log.Error("Failed to create temp dir for git-receive-pack cache: %v", err) - return - } - - defer func() { - if err := util.RemoveAll(tmpDir); err != nil { - log.Error("RemoveAll: %v", err) - } - }() - - if err := git.InitRepository(ctx, tmpDir, true); err != nil { - log.Error("Failed to init bare repo for git-receive-pack cache: %v", err) - return - } - - refs, _, err := git.NewCommand(ctx, "receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Dir: tmpDir}) - if err != nil { - log.Error(fmt.Sprintf("%v - %s", err, string(refs))) - } - - log.Debug("populating infoRefsCache: \n%s", string(refs)) - infoRefsCache = refs - }) - - ctx.RespHeader().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") - ctx.RespHeader().Set("Pragma", "no-cache") - ctx.RespHeader().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") - ctx.RespHeader().Set("Content-Type", "application/x-git-receive-pack-advertisement") - _, _ = ctx.Write(packetWrite("# service=git-receive-pack\n")) - _, _ = ctx.Write([]byte("0000")) - _, _ = ctx.Write(infoRefsCache) -} - -type serviceConfig struct { - UploadPack bool - ReceivePack bool - Env []string -} - -type serviceHandler struct { - cfg *serviceConfig - w http.ResponseWriter - r *http.Request - dir string - environ []string -} - -func (h *serviceHandler) setHeaderNoCache() { - h.w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") - h.w.Header().Set("Pragma", "no-cache") - h.w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") -} - -func (h *serviceHandler) setHeaderCacheForever() { - now := time.Now().Unix() - expires := now + 31536000 - h.w.Header().Set("Date", fmt.Sprintf("%d", now)) - h.w.Header().Set("Expires", fmt.Sprintf("%d", expires)) - h.w.Header().Set("Cache-Control", "public, max-age=31536000") -} - -func containsParentDirectorySeparator(v string) bool { - if !strings.Contains(v, "..") { - return false - } - for _, ent := range strings.FieldsFunc(v, isSlashRune) { - if ent == ".." { - return true - } - } - return false -} - -func isSlashRune(r rune) bool { return r == '/' || r == '\\' } - -func (h *serviceHandler) sendFile(contentType, file string) { - if containsParentDirectorySeparator(file) { - log.Error("request file path contains invalid path: %v", file) - h.w.WriteHeader(http.StatusBadRequest) - return - } - reqFile := path.Join(h.dir, file) - - fi, err := os.Stat(reqFile) - if os.IsNotExist(err) { - h.w.WriteHeader(http.StatusNotFound) - return - } - - h.w.Header().Set("Content-Type", contentType) - h.w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) - h.w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat)) - http.ServeFile(h.w, h.r, reqFile) -} - -// one or more key=value pairs separated by colons -var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`) - -func prepareGitCmdWithAllowedService(service string, h *serviceHandler) (*git.Command, error) { - if service == "receive-pack" && h.cfg.ReceivePack { - return git.NewCommand(h.r.Context(), "receive-pack"), nil - } - if service == "upload-pack" && h.cfg.UploadPack { - return git.NewCommand(h.r.Context(), "upload-pack"), nil - } - - return nil, fmt.Errorf("service %q is not allowed", service) -} - -func serviceRPC(h *serviceHandler, service string) { - defer func() { - if err := h.r.Body.Close(); err != nil { - log.Error("serviceRPC: Close: %v", err) - } - }() - - expectedContentType := fmt.Sprintf("application/x-git-%s-request", service) - if h.r.Header.Get("Content-Type") != expectedContentType { - log.Error("Content-Type (%q) doesn't match expected: %q", h.r.Header.Get("Content-Type"), expectedContentType) - h.w.WriteHeader(http.StatusUnauthorized) - return - } - - cmd, err := prepareGitCmdWithAllowedService(service, h) - if err != nil { - log.Error("Failed to prepareGitCmdWithService: %v", err) - h.w.WriteHeader(http.StatusUnauthorized) - return - } - - h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service)) - - reqBody := h.r.Body - - // Handle GZIP. - if h.r.Header.Get("Content-Encoding") == "gzip" { - reqBody, err = gzip.NewReader(reqBody) - if err != nil { - log.Error("Fail to create gzip reader: %v", err) - h.w.WriteHeader(http.StatusInternalServerError) - return - } - } - - // set this for allow pre-receive and post-receive execute - h.environ = append(h.environ, "SSH_ORIGINAL_COMMAND="+service) - - if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { - h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) - } - - var stderr bytes.Buffer - cmd.AddArguments("--stateless-rpc").AddDynamicArguments(h.dir) - cmd.SetDescription(fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.dir)) - if err := cmd.Run(&git.RunOpts{ - Dir: h.dir, - Env: append(os.Environ(), h.environ...), - Stdout: h.w, - Stdin: reqBody, - Stderr: &stderr, - UseContextTimeout: true, - }); err != nil { - if err.Error() != "signal: killed" { - log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.dir, err, stderr.String()) - } - return - } -} - -// ServiceUploadPack implements Git Smart HTTP protocol -func ServiceUploadPack(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - serviceRPC(h, "upload-pack") - } -} - -// ServiceReceivePack implements Git Smart HTTP protocol -func ServiceReceivePack(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - serviceRPC(h, "receive-pack") - } -} - -func getServiceType(r *http.Request) string { - serviceType := r.FormValue("service") - if !strings.HasPrefix(serviceType, "git-") { - return "" - } - return strings.TrimPrefix(serviceType, "git-") -} - -func updateServerInfo(ctx gocontext.Context, dir string) []byte { - out, _, err := git.NewCommand(ctx, "update-server-info").RunStdBytes(&git.RunOpts{Dir: dir}) - if err != nil { - log.Error(fmt.Sprintf("%v - %s", err, string(out))) - } - return out -} - -func packetWrite(str string) []byte { - s := strconv.FormatInt(int64(len(str)+4), 16) - if len(s)%4 != 0 { - s = strings.Repeat("0", 4-len(s)%4) + s - } - return []byte(s + str) -} - -// GetInfoRefs implements Git dumb HTTP -func GetInfoRefs(ctx *context.Context) { - h := httpBase(ctx) - if h == nil { - return - } - h.setHeaderNoCache() - service := getServiceType(h.r) - cmd, err := prepareGitCmdWithAllowedService(service, h) - if err == nil { - if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) { - h.environ = append(h.environ, "GIT_PROTOCOL="+protocol) - } - h.environ = append(os.Environ(), h.environ...) - - refs, _, err := cmd.AddArguments("--stateless-rpc", "--advertise-refs", ".").RunStdBytes(&git.RunOpts{Env: h.environ, Dir: h.dir}) - if err != nil { - log.Error(fmt.Sprintf("%v - %s", err, string(refs))) - } - - h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service)) - h.w.WriteHeader(http.StatusOK) - _, _ = h.w.Write(packetWrite("# service=git-" + service + "\n")) - _, _ = h.w.Write([]byte("0000")) - _, _ = h.w.Write(refs) - } else { - updateServerInfo(ctx, h.dir) - h.sendFile("text/plain; charset=utf-8", "info/refs") - } -} - -// GetTextFile implements Git dumb HTTP -func GetTextFile(p string) func(*context.Context) { - return func(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - h.setHeaderNoCache() - file := ctx.Params("file") - if file != "" { - h.sendFile("text/plain", "objects/info/"+file) - } else { - h.sendFile("text/plain", p) - } - } - } -} - -// GetInfoPacks implements Git dumb HTTP -func GetInfoPacks(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - h.setHeaderCacheForever() - h.sendFile("text/plain; charset=utf-8", "objects/info/packs") - } -} - -// GetLooseObject implements Git dumb HTTP -func GetLooseObject(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - h.setHeaderCacheForever() - h.sendFile("application/x-git-loose-object", fmt.Sprintf("objects/%s/%s", - ctx.Params("head"), ctx.Params("hash"))) - } -} - -// GetPackFile implements Git dumb HTTP -func GetPackFile(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - h.setHeaderCacheForever() - h.sendFile("application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack") - } -} - -// GetIdxFile implements Git dumb HTTP -func GetIdxFile(ctx *context.Context) { - h := httpBase(ctx) - if h != nil { - h.setHeaderCacheForever() - h.sendFile("application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx") - } -} diff --git a/routers/web/repo/http_test.go b/routers/web/repo/http_test.go deleted file mode 100644 index 5ba8de3d63..0000000000 --- a/routers/web/repo/http_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repo - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestContainsParentDirectorySeparator(t *testing.T) { - tests := []struct { - v string - b bool - }{ - { - v: `user2/repo1/info/refs`, - b: false, - }, - { - v: `user2/repo1/HEAD`, - b: false, - }, - { - v: `user2/repo1/some.../strange_file...mp3`, - b: false, - }, - { - v: `user2/repo1/../../custom/conf/app.ini`, - b: true, - }, - { - v: `user2/repo1/objects/info/..\..\..\..\custom\conf\app.ini`, - b: true, - }, - } - - for i := range tests { - assert.EqualValues(t, tests[i].b, containsParentDirectorySeparator(tests[i].v)) - } -} diff --git a/routers/web/web.go b/routers/web/web.go index a6e2d042dd..4385e48983 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -174,6 +174,8 @@ func Routes(ctx gocontext.Context) *web.Route { return routes } +var ignSignInAndCsrf = auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{DisableCSRF: true}) + // registerRoutes register routes func registerRoutes(m *web.Route) { reqSignIn := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: true}) @@ -181,7 +183,6 @@ func registerRoutes(m *web.Route) { // TODO: rename them to "optSignIn", which means that the "sign-in" could be optional, depends on the VerifyOptions (RequireSignInView) ignSignIn := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: setting.Service.RequireSignInView}) ignExploreSignIn := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{SignInRequired: setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView}) - ignSignInAndCsrf := auth_service.VerifyAuthWithOptions(&auth_service.VerifyOptions{DisableCSRF: true}) validation.AddBindingRules() linkAccountEnabled := func(ctx *context.Context) { @@ -1377,19 +1378,7 @@ func registerRoutes(m *web.Route) { }) }, ignSignInAndCsrf, lfsServerEnabled) - m.Group("", func() { - m.PostOptions("/git-upload-pack", repo.ServiceUploadPack) - m.PostOptions("/git-receive-pack", repo.ServiceReceivePack) - m.GetOptions("/info/refs", repo.GetInfoRefs) - m.GetOptions("/HEAD", repo.GetTextFile("HEAD")) - m.GetOptions("/objects/info/alternates", repo.GetTextFile("objects/info/alternates")) - m.GetOptions("/objects/info/http-alternates", repo.GetTextFile("objects/info/http-alternates")) - m.GetOptions("/objects/info/packs", repo.GetInfoPacks) - m.GetOptions("/objects/info/{file:[^/]*}", repo.GetTextFile("")) - m.GetOptions("/objects/{head:[0-9a-f]{2}}/{hash:[0-9a-f]{38}}", repo.GetLooseObject) - m.GetOptions("/objects/pack/pack-{file:[0-9a-f]{40}}.pack", repo.GetPackFile) - m.GetOptions("/objects/pack/pack-{file:[0-9a-f]{40}}.idx", repo.GetIdxFile) - }, ignSignInAndCsrf, repo.HTTPGitEnabledHandler, repo.CorsHandler(), context_service.UserAssignmentWeb()) + gitHTTPRouters(m) }) }) // ***** END: Repository ***** -- cgit v1.2.3