Files
opengist/internal/web/handlers/git/http.go
T
Thomas 60b4676cb3
Go CI / Build (1.26, macOS-latest) (push) Has been cancelled
Go CI / Build (1.26, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.26, windows-latest) (push) Has been cancelled
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.26, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.26, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.26, ubuntu-latest) (push) Has been cancelled
Improve Git handler security (#769)
2026-07-15 16:16:57 +02:00

120 lines
4.4 KiB
Go

package git
import (
"regexp"
"strings"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
)
// gitRoute maps a Git smart/dumb HTTP URL (matched as a regexp) and method to
// the handler that serves it.
type gitRoute struct {
gitUrl string
method string
handler func(ctx *context.Context) error
}
var routes = []gitRoute{
{"(.*?)/git-upload-pack$", "POST", uploadPack},
{"(.*?)/git-receive-pack$", "POST", receivePack},
{"(.*?)/info/refs$", "GET", infoRefs},
{"(.*?)/HEAD$", "GET", textFile},
{"(.*?)/objects/info/alternates$", "GET", textFile},
{"(.*?)/objects/info/http-alternates$", "GET", textFile},
{"(.*?)/objects/info/packs$", "GET", infoPacks},
{"(.*?)/objects/info/[^/]*$", "GET", textFile},
{"(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$", "GET", looseObject},
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.pack$", "GET", packFile},
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$", "GET", idxFile},
}
// GitHttp is the entry point for all Git-over-HTTP requests. It resolves the
// matching route, classifies the request (init / pull / push) and dispatches to
// the relevant handler once access has been authorized.
func GitHttp(ctx *context.Context) error {
route := findMatchingRoute(ctx)
if route == nil {
return ctx.NotFound("Gist not found") // regular 404 for non-git routes
}
gist := ctx.GetData("gist").(*db.Gist)
gistExists := gist.ID != 0
initKind, initToken := classifyInitRequest(ctx.Request().URL.Path)
isInfoRefs := strings.HasSuffix(route.gitUrl, "/info/refs$")
isPull := ctx.QueryParam("service") == "git-upload-pack" ||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") && !isInfoRefs
isPush := ctx.QueryParam("service") == "git-receive-pack" ||
strings.HasSuffix(ctx.Request().URL.Path, "git-receive-pack") && !isInfoRefs
// Anything that is neither an init request, a smart pull, nor a push is a
// dumb-protocol read: GET .../info/refs (no service), .../HEAD,
// .../objects/*. These stream repository files straight off disk, so they
// are read operations equivalent to a pull and must be authorized as one —
// never served merely because an Authorization header is present.
isDumb := initKind == initNone && !isPull && !isPush
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
allow, err := auth.ShouldAllowUnauthenticatedGistAccess(handlers.ContextAuthInfo{Context: ctx}, true)
if err != nil {
log.Fatal().Err(err).Msg("Cannot check if unauthenticated access is allowed")
}
// No need to authenticate if the user wants to clone/pull (smart or dumb) ;
// a non-private gist ; that exists ; where unauthenticated access is allowed
// in the instance
if (isPull || isDumb) && gist.Private != db.PrivateVisibility && gistExists && allow {
return route.handler(ctx)
}
// Every other case needs credentials:
// - user wants to push the gist
// - user wants to clone/pull a private gist
// - user wants to clone/pull a non-private gist but unauthenticated access is not allowed
// - gist is not found ; has no right to clone/pull (obfuscation)
// - admin setting to require login is set to true
authUsername, authPassword, err := parseAuthHeader(ctx)
if err != nil {
return basicAuth(ctx)
}
switch {
case initKind != initNone:
return handleInit(ctx, route, initKind, initToken, authUsername, authPassword)
case isPull || isDumb:
return handlePull(ctx, route, gist, gistExists, authUsername, authPassword)
case isPush:
return handlePush(ctx, route, gist, gistExists, authUsername, authPassword)
default:
return ctx.NotFound("Gist not found")
}
}
// setGistContext points the request at a specific gist and its repository path,
// overriding whatever the soft-init middleware put in place.
func setGistContext(ctx *context.Context, gist *db.Gist) {
ctx.SetData("gist", gist)
ctx.SetData("repositoryPath", git.RepositoryPath(gist.User.Username, gist.Uuid))
}
func findMatchingRoute(ctx *context.Context) *gitRoute {
for i := range routes {
route := &routes[i]
matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
if ctx.Request().Method == route.method && matched {
if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") {
continue
}
return route
}
}
return nil
}