~gitpocket/gitpocket

1

fix(security): harden proxy trust, lockouts and hooks

commit
af5ee8972884b21f9998e87226fd0050ffcd5db7 browse tree
parent
ca3bf534ca0980572c3ad1bea3b7fe2b06172952
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-20 20:35 UTC

10 files changed, +80 −19

account.go modified

@@ -135,8 +135,8 @@ func (s *Server) totpLogin(w http.ResponseWriter, r *http.Request) {
135135 http.Redirect(w, r, "/login", http.StatusSeeOther)
136136 return
137137 }
138- ip := clientIP(r)
139- if s.limiter.blocked(ip) {
138+ ip, who := clientIP(r), "totp:"+u.Name
139+ if s.limiter.blocked(ip) || s.limiter.blocked(who) {
140140 s.fail(w, r, 429, "Too many attempts, try again in a few minutes")
141141 return
142142 }
@@ -147,6 +147,7 @@ func (s *Server) totpLogin(w http.ResponseWriter, r *http.Request) {
147147 s.store.SetTOTP(u.ID, a.TOTP, left)
148148 } else {
149149 s.limiter.fail(ip)
150+ s.limiter.fail(who)
150151 w.WriteHeader(401)
151152 s.render(w, r, "totp", page{Title: "Two factor", Error: "Wrong code"})
152153 return

avatar.go modified

@@ -7,6 +7,7 @@ import (
77 _ "image/gif"
88 _ "image/jpeg"
99 "image/png"
10+ "sync"
1011
1112 "golang.org/x/image/draw"
1213 _ "golang.org/x/image/webp"
@@ -14,14 +15,18 @@ import (
1415
1516 const avatarSize = 256
1617
18+var decoding sync.Mutex
19+
1720 func cropAvatar(data []byte, x, y, size int) ([]byte, error) {
1821 cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
1922 if err != nil {
2023 return nil, errors.New("Avatar must be a PNG, JPEG, GIF or WebP image")
2124 }
22- if cfg.Width*cfg.Height > 25_000_000 {
23- return nil, errors.New("Avatar image is too large, keep it under 25 megapixels")
25+ if cfg.Width > 6000 || cfg.Height > 6000 || cfg.Width*cfg.Height > 16_000_000 {
26+ return nil, errors.New("Avatar image is too large, keep it under 16 megapixels")
2427 }
28+ decoding.Lock()
29+ defer decoding.Unlock()
2530 img, _, err := image.Decode(bytes.NewReader(data))
2631 if err != nil {
2732 return nil, errors.New("Avatar must be a PNG, JPEG, GIF or WebP image")

edit.go modified

@@ -49,11 +49,13 @@ func hook(root, kind, spec string) int {
4949 name := strings.TrimSuffix(repoDir, ".git")
5050 store, err := openStore(filepath.Join(root, "gitpocket.db"), true)
5151 if err != nil {
52- return 0
52+ fmt.Fprintln(os.Stderr, "gitpocket: database unavailable, push refused")
53+ return 1
5354 }
5455 repo, err := store.RepoByPath(owner, name)
5556 if err != nil {
56- return 0
57+ fmt.Fprintln(os.Stderr, "gitpocket: unknown repository, push refused")
58+ return 1
5759 }
5860 for _, kv := range os.Environ() {
5961 if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") {

extra.go modified

@@ -282,6 +282,12 @@ func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spe
282282 }
283283
284284 func writeArchive(ctx context.Context, w io.Writer, dir, format, name, commit string) error {
285+ select {
286+ case archiveSlots <- struct{}{}:
287+ case <-ctx.Done():
288+ return ctx.Err()
289+ }
290+ defer func() { <-archiveSlots }()
285291 gitFormat := format
286292 if format == "tar.gz" {
287293 gitFormat = "tar"

git.go modified

@@ -8,6 +8,7 @@ import (
88 "os"
99 "os/exec"
1010 "path"
11+ "runtime"
1112 "slices"
1213 "strconv"
1314 "strings"
@@ -80,7 +81,30 @@ func (g Git) run(stdin []byte, args ...string) ([]byte, error) {
8081 return g.runEnv(nil, stdin, args...)
8182 }
8283
84+const maxOutput = 16 << 20
85+
86+var (
87+ gitSlots = make(chan struct{}, max(4, 2*runtime.NumCPU()))
88+ archiveSlots = make(chan struct{}, 2)
89+ errTooLarge = errors.New("output too large")
90+)
91+
92+type cappedBuffer struct {
93+ bytes.Buffer
94+ over bool
95+}
96+
97+func (b *cappedBuffer) Write(p []byte) (int, error) {
98+ if b.Len()+len(p) > maxOutput {
99+ b.over = true
100+ return 0, errTooLarge
101+ }
102+ return b.Buffer.Write(p)
103+}
104+
83105 func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error) {
106+ gitSlots <- struct{}{}
107+ defer func() { <-gitSlots }()
84108 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
85109 defer cancel()
86110 cmd := exec.CommandContext(ctx, "git", append([]string{"-C", g.Dir}, args...)...)
@@ -95,10 +119,14 @@ func (g Git) runEnv(env []string, stdin []byte, args ...string) ([]byte, error)
95119 if stdin != nil {
96120 cmd.Stdin = bytes.NewReader(stdin)
97121 }
98- var out, errb bytes.Buffer
122+ var out cappedBuffer
123+ var errb bytes.Buffer
99124 cmd.Stdout = &out
100125 cmd.Stderr = &errb
101126 if err := cmd.Run(); err != nil {
127+ if out.over {
128+ return nil, errTooLarge
129+ }
102130 msg := strings.TrimSpace(errb.String())
103131 if msg == "" {
104132 msg = err.Error()
@@ -486,6 +514,8 @@ func (g Git) LastCommits(commit, dir string, names []string) map[string]Commit {
486514 }
487515
488516 func (g Git) Stream(w io.Writer, commit, filePath string) error {
517+ archiveSlots <- struct{}{}
518+ defer func() { <-archiveSlots }()
489519 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
490520 defer cancel()
491521 cmd := exec.CommandContext(ctx, "git", "-C", g.Dir, "cat-file", "blob", commit+":"+filePath)

limit.go modified

@@ -56,13 +56,15 @@ func (l *limiter) fail(key string) {
5656 }
5757
5858 func clientIP(r *http.Request) string {
59- if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
60- first, _, _ := strings.Cut(xff, ",")
61- return strings.TrimSpace(first)
62- }
6359 host, _, err := net.SplitHostPort(r.RemoteAddr)
6460 if err != nil {
65- return r.RemoteAddr
61+ host = r.RemoteAddr
62+ }
63+ if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
64+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
65+ hops := strings.Split(xff, ",")
66+ return strings.TrimSpace(hops[len(hops)-1])
67+ }
6668 }
6769 return host
6870 }

main.go modified

@@ -206,6 +206,8 @@ func serve(root string, args []string) {
206206 Addr: *listen,
207207 Handler: newServer(store, root, *host, *name, *open, *sshUser).handler(),
208208 ReadHeaderTimeout: 10 * time.Second,
209+ IdleTimeout: 2 * time.Minute,
210+ MaxHeaderBytes: 64 << 10,
209211 }
210212 log.Printf("gitpocket listening on %s for %s", *listen, *host)
211213 log.Fatal(srv.ListenAndServe())

search.go modified

@@ -7,6 +7,7 @@ import (
77 "strconv"
88 "strings"
99 "sync"
10+ "time"
1011 )
1112
1213 type language struct {
@@ -48,6 +49,7 @@ func vendored(name string) bool {
4849
4950 type langEntry struct {
5051 head string
52+ stamp time.Time
5153 langs []language
5254 }
5355
@@ -58,14 +60,22 @@ var langCache = struct {
5860
5961 func (s *Server) repoLanguages(repo Repo) []language {
6062 g := Git{Dir: s.repoDir(repo)}
63+ stamp := repoUpdated(g.Dir)
64+ langCache.Lock()
65+ e, ok := langCache.m[repo.ID]
66+ langCache.Unlock()
67+ if ok && e.stamp.Equal(stamp) {
68+ return e.langs
69+ }
6170 head, err := g.Resolve("HEAD")
6271 if err != nil {
6372 return nil
6473 }
65- langCache.Lock()
66- e, ok := langCache.m[repo.ID]
67- langCache.Unlock()
6874 if ok && e.head == head {
75+ e.stamp = stamp
76+ langCache.Lock()
77+ langCache.m[repo.ID] = e
78+ langCache.Unlock()
6979 return e.langs
7080 }
7181 out, err := g.run(nil, "ls-tree", "-r", "-l", "-z", head)
@@ -105,7 +115,7 @@ func (s *Server) repoLanguages(repo Repo) []language {
105115 return strings.Compare(a.Name, b.Name)
106116 })
107117 langCache.Lock()
108- langCache.m[repo.ID] = langEntry{head, langs}
118+ langCache.m[repo.ID] = langEntry{head, stamp, langs}
109119 langCache.Unlock()
110120 return langs
111121 }

smart.go modified

@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "io"
88 "net/http"
9+ "os"
910 "os/exec"
1011 "strings"
1112 "time"
@@ -101,7 +102,7 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
101102 }
102103 cmd := exec.CommandContext(ctx, "git", strings.TrimPrefix(service, "git-"), "--stateless-rpc", dir)
103104 cmd.Stdin = body
104- cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent"}
105+ cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
105106 stdout, err := cmd.StdoutPipe()
106107 if err != nil {
107108 http.Error(w, "git failed", 500)

web.go modified

@@ -260,14 +260,16 @@ func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
260260
261261 func (s *Server) login(w http.ResponseWriter, r *http.Request) {
262262 ip := clientIP(r)
263- if s.limiter.blocked(ip) {
263+ name := strings.TrimSpace(r.FormValue("name"))
264+ if s.limiter.blocked(ip) || s.limiter.blocked("~"+name) {
264265 w.WriteHeader(429)
265266 s.render(w, r, "login", page{Title: "Log in", Error: "Too many attempts, try again in a few minutes"})
266267 return
267268 }
268- u, ok := s.store.CheckPassword(strings.TrimSpace(r.FormValue("name")), r.FormValue("password"))
269+ u, ok := s.store.CheckPassword(name, r.FormValue("password"))
269270 if !ok {
270271 s.limiter.fail(ip)
272+ s.limiter.fail("~" + name)
271273 w.WriteHeader(401)
272274 s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"})
273275 return