~gitpocket/gitpocket

1

fix(security): add csp and validate branches and emails

commit
123313819a86951e89e943e620e28ffe783cccb7 browse tree
parent
af5ee8972884b21f9998e87226fd0050ffcd5db7
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-21 18:09 UTC

12 files changed, +312 −62

edit.go modified

@@ -150,7 +150,7 @@ func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo,
150150 s.fail(w, r, 403, "This repository is archived")
151151 return rp, g, false
152152 }
153- if _, err := g.Resolve("refs/heads/" + branch); err != nil {
153+ if _, err := g.Resolve("refs/heads/" + branch); err != nil || !validBranch(branch) {
154154 s.fail(w, r, 404, "Edits need a branch, not a tag or commit")
155155 return rp, g, false
156156 }

extra.go modified

@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "html/template"
1010 "io"
11+ "mime"
1112 "net/http"
1213 "os"
1314 "os/exec"
@@ -51,6 +52,7 @@ func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request, repo Repo) {
5152 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
5253 return
5354 }
55+ s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID)
5456 http.Redirect(w, r, "/"+renamed.Path()+"/settings", http.StatusSeeOther)
5557 }
5658
@@ -86,6 +88,7 @@ func (s *Server) transferRepo(w http.ResponseWriter, r *http.Request, repo Repo)
8688 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
8789 return
8890 }
91+ s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID)
8992 s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()),
9093 fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path()))
9194 http.Redirect(w, r, "/"+moved.Path(), http.StatusSeeOther)
@@ -253,7 +256,12 @@ func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spe
253256 s.fail(w, r, 404, "No such ref")
254257 return
255258 }
256- safe := strings.NewReplacer("/", "-", "\\", "-").Replace(spec)
259+ safe := strings.Map(func(r rune) rune {
260+ if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-' {
261+ return r
262+ }
263+ return '-'
264+ }, spec)
257265 name := repo.Name + "-" + safe
258266 file := name + "." + format
259267 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
@@ -270,14 +278,14 @@ func (s *Server) download(w http.ResponseWriter, r *http.Request, repo Repo, spe
270278 }
271279 w.Header().Set("ETag", `"`+commit+"-"+format+`"`)
272280 if commit == spec {
273- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
281+ w.Header().Set("Cache-Control", cacheScope(repo)+", max-age=31536000, immutable")
274282 }
275283 if r.Header.Get("If-None-Match") == `"`+commit+"-"+format+`"` {
276284 w.WriteHeader(http.StatusNotModified)
277285 return
278286 }
279287 w.Header().Set("Content-Type", "application/octet-stream")
280- w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`)
288+ w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": file}))
281289 writeArchive(ctx, w, g.Dir, format, name, commit)
282290 }
283291
@@ -436,55 +444,99 @@ func (s *Server) userFeed(w http.ResponseWriter, r *http.Request, owner User) {
436444 writeFeed(w, feed)
437445 }
438446
439-type activityCache struct {
440- sync.Mutex
441- entries map[int64]activityEntry
447+type repoHistory struct {
448+ stamp time.Time
449+ commits []historyCommit
442450 }
443451
444-type activityEntry struct {
445- at time.Time
446- counts map[string]int
452+type historyCommit struct {
453+ hash uint64
454+ day int32
455+ email int32
447456 }
448457
449-var activities = activityCache{entries: map[int64]activityEntry{}}
458+var histories = struct {
459+ sync.Mutex
460+ repos map[int64]repoHistory
461+ emails map[string]int32
462+}{repos: map[int64]repoHistory{}, emails: map[string]int32{}}
450463
451-func (s *Server) activityCounts(owner User) map[string]int {
452- activities.Lock()
453- if e, ok := activities.entries[owner.ID]; ok && time.Since(e.at) < 10*time.Minute {
454- activities.Unlock()
455- return e.counts
464+func (s *Server) history(repo Repo) []historyCommit {
465+ dir := s.repoDir(repo)
466+ stamp := repoUpdated(dir)
467+ histories.Lock()
468+ h, ok := histories.repos[repo.ID]
469+ histories.Unlock()
470+ if ok && h.stamp.Equal(stamp) {
471+ return h.commits
472+ }
473+ out, err := Git{Dir: dir}.run(nil, "log", "--all", "--no-merges", "--format=%H%x09%at%x09%ae")
474+ if err != nil {
475+ return nil
476+ }
477+ var commits []historyCommit
478+ histories.Lock()
479+ for _, line := range strings.Split(string(out), "\n") {
480+ f := strings.Split(line, "\t")
481+ if len(f) != 3 || len(f[0]) < 16 {
482+ continue
483+ }
484+ hash, err := strconv.ParseUint(f[0][:16], 16, 64)
485+ if err != nil {
486+ continue
487+ }
488+ sec, _ := strconv.ParseInt(f[1], 10, 64)
489+ email := strings.ToLower(f[2])
490+ id, ok := histories.emails[email]
491+ if !ok {
492+ id = int32(len(histories.emails))
493+ histories.emails[email] = id
494+ }
495+ commits = append(commits, historyCommit{hash, int32(sec / 86400), id})
456496 }
457- activities.Unlock()
497+ histories.repos[repo.ID] = repoHistory{stamp, commits}
498+ histories.Unlock()
499+ return commits
500+}
501+
502+func (s *Server) activityCounts(owner User) map[string]int {
458503 counts := map[string]int{}
459- email := strings.ToLower(s.store.VerifiedEmail(owner.ID))
460504 org := s.store.IsOrg(owner.ID)
505+ email := strings.ToLower(s.store.VerifiedEmail(owner.ID))
461506 if email == "" && !org {
462507 return counts
463508 }
464- seen := map[string]bool{}
509+ want := int32(-1)
510+ if !org {
511+ histories.Lock()
512+ id, ok := histories.emails[email]
513+ histories.Unlock()
514+ if ok {
515+ want = id
516+ }
517+ }
518+ seen := map[uint64]bool{}
465519 repos, _ := s.store.PublicRepos("", "")
466520 for _, repo := range repos {
467521 if org && repo.Owner.ID != owner.ID {
468522 continue
469523 }
470- g := Git{Dir: s.repoDir(repo)}
471- out, err := g.run(nil, "log", "--all", "--no-merges", "--format=%H%x09%at%x09%ae")
472- if err != nil {
473- continue
524+ commits := s.history(repo)
525+ if !org && want < 0 {
526+ histories.Lock()
527+ if id, ok := histories.emails[email]; ok {
528+ want = id
529+ }
530+ histories.Unlock()
474531 }
475- for _, line := range strings.Split(string(out), "\n") {
476- f := strings.Split(line, "\t")
477- if len(f) != 3 || (!org && strings.ToLower(f[2]) != email) || seen[f[0]] {
532+ for _, c := range commits {
533+ if (!org && c.email != want) || seen[c.hash] {
478534 continue
479535 }
480- seen[f[0]] = true
481- sec, _ := strconv.ParseInt(f[1], 10, 64)
482- counts[time.Unix(sec, 0).UTC().Format("2006-01-02")]++
536+ seen[c.hash] = true
537+ counts[time.Unix(int64(c.day)*86400, 0).UTC().Format("2006-01-02")]++
483538 }
484539 }
485- activities.Lock()
486- activities.entries[owner.ID] = activityEntry{time.Now(), counts}
487- activities.Unlock()
488540 return counts
489541 }
490542

git.go modified

@@ -672,7 +672,26 @@ func isBinary(data []byte) bool {
672672 return bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head)
673673 }
674674
675+func validBranch(name string) bool {
676+ if name == "" || len(name) > 255 || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
677+ strings.HasSuffix(name, ".") || strings.HasSuffix(name, ".lock") || strings.Contains(name, "..") || strings.Contains(name, "//") ||
678+ strings.Contains(name, "@{") || strings.Contains(name, "/.") || strings.HasPrefix(name, ".") {
679+ return false
680+ }
681+ for _, r := range name {
682+ if r < 0x20 || r == 0x7f || strings.ContainsRune(" ~^:?*[\\", r) {
683+ return false
684+ }
685+ }
686+ return true
687+}
688+
675689 func cleanPath(p string) (string, bool) {
690+ for _, r := range p {
691+ if r < 0x20 || r == 0x7f {
692+ return "", false
693+ }
694+ }
676695 p = strings.Trim(p, "/")
677696 if p == "" {
678697 return "", true

labels.go modified

@@ -151,7 +151,7 @@ func (s *Store) Protection(repo int64, branch string) (Protection, bool) {
151151
152152 func (s *Store) SetProtection(repo int64, branch string, requirePR bool, approvals int) error {
153153 branch = strings.TrimSpace(branch)
154- if branch == "" || strings.HasPrefix(branch, "-") || strings.Contains(branch, " ") {
154+ if !validBranch(branch) {
155155 return errors.New("Give a branch name")
156156 }
157157 if approvals < 0 || approvals > 10 {

limit.go modified

@@ -68,3 +68,71 @@ func clientIP(r *http.Request) string {
6868 }
6969 return host
7070 }
71+
72+type bucket struct {
73+ tokens float64
74+ last time.Time
75+}
76+
77+type buckets struct {
78+ mu sync.Mutex
79+ m map[string]*bucket
80+ rate float64
81+ burst float64
82+}
83+
84+func newBuckets(rate, burst float64) *buckets {
85+ return &buckets{m: map[string]*bucket{}, rate: rate, burst: burst}
86+}
87+
88+func (b *buckets) allow(key string) bool {
89+ b.mu.Lock()
90+ defer b.mu.Unlock()
91+ now := time.Now()
92+ k, ok := b.m[key]
93+ if !ok {
94+ if len(b.m) >= 20000 {
95+ for name, v := range b.m {
96+ if v.tokens+now.Sub(v.last).Seconds()*b.rate >= b.burst {
97+ delete(b.m, name)
98+ }
99+ }
100+ if len(b.m) >= 20000 {
101+ return false
102+ }
103+ }
104+ k = &bucket{tokens: b.burst, last: now}
105+ b.m[key] = k
106+ }
107+ k.tokens = min(b.burst, k.tokens+now.Sub(k.last).Seconds()*b.rate)
108+ k.last = now
109+ if k.tokens < 1 {
110+ return false
111+ }
112+ k.tokens--
113+ return true
114+}
115+
116+func heavyRequest(r *http.Request) bool {
117+ p := r.URL.Path
118+ q := r.URL.Query()
119+ return p == "/search" || strings.Contains(p, "/archive/") || strings.Contains(p, "/blame/") ||
120+ strings.Contains(p, "/interdiff") || (strings.HasSuffix(p, "/search") && q.Get("q") != "") ||
121+ (strings.Contains(p, "/log") && q.Get("q") != "") || p == "/login" || p == "/register"
122+}
123+
124+func (s *Server) throttle(next http.Handler) http.Handler {
125+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+ if strings.HasPrefix(r.URL.Path, "/static/") {
127+ next.ServeHTTP(w, r)
128+ return
129+ }
130+ ip := clientIP(r)
131+ if !s.requests.allow(ip) || (heavyRequest(r) && !s.heavy.allow(ip)) {
132+ w.Header().Set("Retry-After", "10")
133+ http.Error(w, "too many requests, slow down", http.StatusTooManyRequests)
134+ return
135+ }
136+ next.ServeHTTP(w, r)
137+ })
138+}

mail.go modified

@@ -89,7 +89,7 @@ func (m *Mailer) Send(to, subject, body string) error {
8989 return err
9090 }
9191 msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%d.%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
92- m.From, to, strings.ReplaceAll(subject, "\n", " "), time.Now().Format(time.RFC1123Z), time.Now().UnixNano(), m.domain(), body)
92+ m.From, to, headerText(subject), time.Now().Format(time.RFC1123Z), time.Now().UnixNano(), m.domain(), body)
9393 if _, err := w.Write([]byte(msg)); err != nil {
9494 return err
9595 }
@@ -120,7 +120,7 @@ func (m *Mailer) domain() string {
120120 }
121121
122122 func (m *Mailer) post(to, subject, body string) error {
123- payload, _ := json.Marshal(map[string]any{"from": m.From, "to": []string{to}, "subject": strings.ReplaceAll(subject, "\n", " "), "text": body})
123+ payload, _ := json.Marshal(map[string]any{"from": m.From, "to": []string{to}, "subject": headerText(subject), "text": body})
124124 req, err := http.NewRequest("POST", m.API, bytes.NewReader(payload))
125125 if err != nil {
126126 return err
@@ -138,3 +138,12 @@ func (m *Mailer) post(to, subject, body string) error {
138138 }
139139 return nil
140140 }
141+
142+func headerText(s string) string {
143+ return strings.Map(func(r rune) rune {
144+ if r < 0x20 || r == 0x7f {
145+ return ' '
146+ }
147+ return r
148+ }, s)
149+}

pull.go modified

@@ -460,7 +460,7 @@ func (s *Server) newPull(w http.ResponseWriter, r *http.Request, repo Repo) {
460460
461461 func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, string, error) {
462462 path, branch, ok := strings.Cut(value, ":")
463- if !ok || branch == "" {
463+ if !ok || !validBranch(branch) {
464464 return Repo{}, "", errors.New("Choose a source branch")
465465 }
466466 owner, name, ok := strings.Cut(strings.TrimPrefix(path, "~"), "/")
@@ -505,7 +505,7 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
505505 return
506506 }
507507 target := r.FormValue("target")
508- if _, err := g.Resolve("refs/heads/" + target); err != nil {
508+ if _, err := g.Resolve("refs/heads/" + target); err != nil || !validBranch(target) {
509509 retry("No such target branch")
510510 return
511511 }

shell.go modified

@@ -60,7 +60,11 @@ func shell(root, name string) int {
6060 }
6161 repo, err := store.RepoByPath(owner, repoName)
6262 if err != nil {
63- return fail("repository not found")
63+ moved, ok := store.Redirect(owner, repoName)
64+ if !ok {
65+ return fail("repository not found")
66+ }
67+ repo = moved
6468 }
6569 canWrite := store.CanWrite(repo, &user)
6670 if repo.Private && !canWrite {
@@ -86,6 +90,10 @@ func shell(root, name string) int {
8690 if p := os.Getenv("GIT_PROTOCOL"); p != "" {
8791 env = append(env, "GIT_PROTOCOL="+p)
8892 }
89- err = syscall.Exec(git, []string{"git", strings.TrimPrefix(verb, "git-"), dir}, env)
93+ argv := []string{"git"}
94+ if verb == "git-receive-pack" {
95+ argv = append(argv, pushLimit(root, repo.Owner.Name)...)
96+ }
97+ err = syscall.Exec(git, append(argv, strings.TrimPrefix(verb, "git-"), dir), env)
9098 return fail(err.Error())
9199 }

smart.go modified

@@ -36,7 +36,14 @@ func (s *Server) basicUser(r *http.Request) (User, bool) {
3636 func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, rest string) {
3737 repo, err := s.store.RepoByPath(owner.Name, name)
3838 if err != nil {
39- http.Error(w, "repository not found", 404)
39+ var viewer *User
40+ if u, ok := s.basicUser(r); ok {
41+ viewer = &u
42+ }
43+ segment, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/~"+owner.Name+"/"), "/")
44+ if !s.redirectMoved(w, r, owner.Name, segment, viewer, http.StatusPermanentRedirect) {
45+ http.Error(w, "repository not found", 404)
46+ }
4047 return
4148 }
4249 service := r.URL.Query().Get("service")
@@ -100,7 +107,14 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
100107 defer gz.Close()
101108 body = gz
102109 }
103- cmd := exec.CommandContext(ctx, "git", strings.TrimPrefix(service, "git-"), "--stateless-rpc", dir)
110+ argv := []string{strings.TrimPrefix(service, "git-"), "--stateless-rpc", dir}
111+ if service == "git-receive-pack" {
112+ argv = append(pushLimit(s.root, repo.Owner.Name), argv...)
113+ if over, used, quota := overQuota(s.root, repo.Owner.Name); quota > 0 && !over {
114+ body = io.LimitReader(body, quota-used+1<<20)
115+ }
116+ }
117+ cmd := exec.CommandContext(ctx, "git", argv...)
104118 cmd.Stdin = body
105119 cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
106120 stdout, err := cmd.StdoutPipe()

static/math.js added

@@ -0,0 +1,9 @@
1+document.addEventListener("DOMContentLoaded", function () {
2+ renderMathInElement(document.body, {
3+ delimiters: [{ left: "\\(", right: "\\)", display: false }, { left: "\\[", right: "\\]", display: true }],
4+ throwOnError: false,
5+ trust: false,
6+ maxExpand: 500,
7+ maxSize: 20
8+ });
9+});

store.go modified

@@ -7,6 +7,7 @@ import (
77 "encoding/hex"
88 "errors"
99 "fmt"
10+ "net/mail"
1011 "regexp"
1112 "strings"
1213 "time"
@@ -77,6 +78,7 @@ var migrations = []string{
7778 `alter table pulls add column draft integer not null default 0`,
7879 `alter table repos add column featured integer not null default 0`,
7980 `alter table users add column org integer not null default 0`,
81+ `alter table users add column admin integer not null default 0`,
8082 }
8183
8284 const extraSchema = `
@@ -269,20 +271,16 @@ func openStore(path string, readonly bool) (*Store, error) {
269271 if _, err := db.Exec(schema); err != nil {
270272 return nil, err
271273 }
274+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema} {
275+ if _, err := db.Exec(ddl); err != nil {
276+ return nil, err
277+ }
278+ }
272279 for _, m := range migrations {
273280 if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") {
274281 return nil, err
275282 }
276283 }
277- if _, err := db.Exec(pullSchema); err != nil {
278- return nil, err
279- }
280- if _, err := db.Exec(collabSchema); err != nil {
281- return nil, err
282- }
283- if _, err := db.Exec(extraSchema); err != nil {
284- return nil, err
285- }
286284 }
287285 return &Store{db}, nil
288286 }
@@ -929,7 +927,7 @@ func (s *Store) Account(user int64) Account {
929927
930928 func (s *Store) SetEmail(user int64, email string) error {
931929 email = strings.TrimSpace(strings.ToLower(email))
932- if email != "" && (!strings.Contains(email, "@") || len(email) > 200) {
930+ if email != "" && !validEmail(email) {
933931 return errors.New("That does not look like an email address")
934932 }
935933 _, err := s.db.Exec(`update users set email = ?, verified = 0 where id = ?`, email, user)
@@ -1076,3 +1074,8 @@ func (s *Store) UseInvite(code string) bool {
10761074 func (r Repo) Path() string {
10771075 return fmt.Sprintf("~%s/%s", r.Owner.Name, r.Name)
10781076 }
1077+
1078+func validEmail(email string) bool {
1079+ addr, err := mail.ParseAddress(email)
1080+ return err == nil && addr.Address == email && len(email) <= 200 && !strings.ContainsAny(email, "\r\n<>,;\"")
1081+}

web.go modified

@@ -7,6 +7,7 @@ import (
77 "encoding/hex"
88 "errors"
99 "fmt"
10+ "html"
1011 "html/template"
1112 "io"
1213 "io/fs"
@@ -42,12 +43,13 @@ type Server struct {
4243 mailer *Mailer
4344 version string
4445 limiter *limiter
46+ requests *buckets
47+ heavy *buckets
4548 store *Store
4649 root string
4750 host string
4851 name string
4952 sshUser string
50- open bool
5153 pages map[string]*template.Template
5254 markdown goldmark.Markdown
5355 policy *bluemonday.Policy
@@ -64,10 +66,13 @@ type page struct {
6466 Error string
6567 Math bool
6668 Data any
69+
70+ Registration string
71+ Admin bool
6772 }
6873
69-func newServer(store *Store, root, host, name string, open bool, sshUser string) *Server {
70- s := &Server{store: store, root: root, host: host, name: name, open: open, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute)}
74+func newServer(store *Store, root, host, name string, sshUser string) *Server {
75+ s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20)}
7176 funcs := template.FuncMap{
7277 "short": func(h string) string {
7378 if len(h) > 10 {
@@ -142,12 +147,16 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p
142147 p.Name = s.name
143148 p.SSHUser = s.sshUser
144149 p.Version = s.version
150+ p.Registration = s.store.Registration()
145151 if d, ok := p.Data.(map[string]any); ok && d["File"] == true {
146152 p.Fluid = true
147153 }
148154 if p.User == nil {
149155 p.User = s.current(r)
150156 }
157+ if p.User != nil {
158+ p.Admin = s.store.IsAdmin(p.User.ID)
159+ }
151160 t, ok := s.pages[name]
152161 if !ok {
153162 http.Error(w, "missing template "+name, 500)
@@ -227,9 +236,11 @@ func (s *Server) handler() http.Handler {
227236 mux.HandleFunc("POST /settings/profile", s.profile)
228237 mux.HandleFunc("POST /settings/avatar", s.avatar)
229238 mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar)
239+ mux.HandleFunc("/admin", s.admin)
240+ mux.HandleFunc("/orgs/new", s.newOrg)
230241 mux.HandleFunc("/", s.route)
231242 cop := http.NewCrossOriginProtection()
232- return cop.Handler(mux)
243+ return secure(s.throttle(cop.Handler(mux)))
233244 }
234245
235246 func (s *Server) index(w http.ResponseWriter, r *http.Request) {
@@ -301,17 +312,22 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
301312 }
302313
303314 func (s *Server) registerForm(w http.ResponseWriter, r *http.Request) {
304- s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Open": s.open, "Invite": r.URL.Query().Get("invite")}})
315+ s.render(w, r, "register", page{Title: "Register", Data: map[string]any{"Mode": s.store.Registration(), "Invite": r.URL.Query().Get("invite")}})
305316 }
306317
307318 func (s *Server) register(w http.ResponseWriter, r *http.Request) {
308319 invite := strings.TrimSpace(r.FormValue("invite"))
309320 retry := func(code int, msg string) {
310321 w.WriteHeader(code)
311- s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Open": s.open, "Invite": invite}})
322+ s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Mode": s.store.Registration(), "Invite": invite}})
323+ }
324+ mode := s.store.Registration()
325+ if mode == "closed" {
326+ retry(403, "Registration is closed")
327+ return
312328 }
313329 ip := clientIP(r)
314- if s.limiter.blocked(ip) {
330+ if s.limiter.blocked(ip) || s.limiter.blocked("register:"+ip) {
315331 retry(429, "Too many attempts, try again in a few minutes")
316332 return
317333 }
@@ -320,7 +336,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
320336 retry(400, err.Error())
321337 return
322338 }
323- if !s.open && !s.store.UseInvite(invite) {
339+ if mode == "invite" && !s.store.UseInvite(invite) {
324340 s.limiter.fail(ip)
325341 retry(400, "That invite is invalid or already used")
326342 return
@@ -330,6 +346,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
330346 retry(400, err.Error())
331347 return
332348 }
349+ s.limiter.fail("register:" + ip)
333350 s.startSession(w, u)
334351 http.Redirect(w, r, "/settings", http.StatusSeeOther)
335352 }
@@ -836,7 +853,9 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
836853 }
837854 repo, err := s.store.RepoByPath(owner.Name, name)
838855 if err != nil {
839- s.fail(w, r, 404, "No such repository")
856+ if !s.redirectMoved(w, r, owner.Name, name, user, http.StatusMovedPermanently) {
857+ s.fail(w, r, 404, "No such repository")
858+ }
840859 return
841860 }
842861 if !s.store.CanRead(repo, user) {
@@ -1046,7 +1065,7 @@ func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template
10461065 if attr == "src" {
10471066 base = rawBase
10481067 }
1049- return []byte(attr + `="` + base + target + `"`)
1068+ return []byte(attr + `="` + html.EscapeString(escapePath(base)) + target + `"`)
10501069 })
10511070 }
10521071 return template.HTML(out)
@@ -1186,7 +1205,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str
11861205 return
11871206 }
11881207 w.Header().Set("Content-Type", kind)
1189- w.Header().Set("Cache-Control", "public, max-age=300")
1208+ w.Header().Set("Cache-Control", cacheScope(repo)+", max-age=300")
11901209 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
11911210 http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
11921211 return
@@ -1485,3 +1504,52 @@ func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, a
14851504 s.render(w, r, "orgsettings", page{Title: "~" + org.Name + " settings", User: u, Error: r.URL.Query().Get("error"),
14861505 Data: map[string]any{"Org": org, "Profile": s.store.Profile(org.ID), "Members": people}})
14871506 }
1507+
1508+func (s *Server) redirectMoved(w http.ResponseWriter, r *http.Request, owner, name string, user *User, code int) bool {
1509+ moved, ok := s.store.Redirect(owner, strings.TrimSuffix(name, ".git"))
1510+ if !ok || !s.store.CanRead(moved, user) {
1511+ return false
1512+ }
1513+ _, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/~"+owner+"/"), "/")
1514+ target := "/" + moved.Path()
1515+ if strings.HasSuffix(name, ".git") {
1516+ target += ".git"
1517+ }
1518+ if rest != "" || strings.HasSuffix(r.URL.Path, "/") {
1519+ target += "/" + rest
1520+ }
1521+ if r.URL.RawQuery != "" {
1522+ target += "?" + r.URL.RawQuery
1523+ }
1524+ http.Redirect(w, r, target, code)
1525+ return true
1526+}
1527+
1528+const contentPolicy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; media-src 'self' https:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
1529+
1530+func secure(next http.Handler) http.Handler {
1531+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1532+ h := w.Header()
1533+ h.Set("Content-Security-Policy", contentPolicy)
1534+ h.Set("X-Content-Type-Options", "nosniff")
1535+ h.Set("X-Frame-Options", "DENY")
1536+ h.Set("Referrer-Policy", "same-origin")
1537+ h.Set("Cross-Origin-Opener-Policy", "same-origin")
1538+ next.ServeHTTP(w, r)
1539+ })
1540+}
1541+
1542+func escapePath(p string) string {
1543+ parts := strings.Split(p, "/")
1544+ for i, part := range parts {
1545+ parts[i] = url.PathEscape(part)
1546+ }
1547+ return strings.Join(parts, "/")
1548+}
1549+
1550+func cacheScope(repo Repo) string {
1551+ if repo.Private {
1552+ return "private"
1553+ }
1554+ return "public"
1555+}