fix(security): escape readme link bases, validate branches and emails, strip header controls, add csp and throttling
12 files changed, +312 −62
- edit.go modified +1 −1
- extra.go modified +83 −31
- git.go modified +19 −0
- labels.go modified +1 −1
- limit.go modified +68 −0
- mail.go modified +11 −2
- pull.go modified +2 −2
- shell.go modified +10 −2
- smart.go modified +16 −2
- static/math.js added +9 −0
- store.go modified +13 −10
- web.go modified +79 −11
edit.go modified
| @@ -150,7 +150,7 @@ func (s *Server) editAllowed(w http.ResponseWriter, r *http.Request, repo Repo, | ||
| 150 | 150 | s.fail(w, r, 403, "This repository is archived") |
| 151 | 151 | return rp, g, false |
| 152 | 152 | } |
| 153 | - if _, err := g.Resolve("refs/heads/" + branch); err != nil { | |
| 153 | + if _, err := g.Resolve("refs/heads/" + branch); err != nil || !validBranch(branch) { | |
| 154 | 154 | s.fail(w, r, 404, "Edits need a branch, not a tag or commit") |
| 155 | 155 | return rp, g, false |
| 156 | 156 | } |
extra.go modified
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "fmt" |
| 9 | 9 | "html/template" |
| 10 | 10 | "io" |
| 11 | + "mime" | |
| 11 | 12 | "net/http" |
| 12 | 13 | "os" |
| 13 | 14 | "os/exec" |
| @@ -51,6 +52,7 @@ func (s *Server) renameRepo(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 51 | 52 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 52 | 53 | return |
| 53 | 54 | } |
| 55 | + s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID) | |
| 54 | 56 | http.Redirect(w, r, "/"+renamed.Path()+"/settings", http.StatusSeeOther) |
| 55 | 57 | } |
| 56 | 58 | |
| @@ -86,6 +88,7 @@ func (s *Server) transferRepo(w http.ResponseWriter, r *http.Request, repo Repo) | ||
| 86 | 88 | http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther) |
| 87 | 89 | return |
| 88 | 90 | } |
| 91 | + s.store.AddRedirect(repo.Owner.Name, repo.Name, repo.ID) | |
| 89 | 92 | s.notify(s.store.VerifiedEmail(target.ID), fmt.Sprintf("[%s] repository transferred to you", moved.Path()), |
| 90 | 93 | fmt.Sprintf("~%s transferred %s to you. It now lives at https://%s/%s\n", repo.Owner.Name, repo.Name, s.host, moved.Path())) |
| 91 | 94 | 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 | ||
| 253 | 256 | s.fail(w, r, 404, "No such ref") |
| 254 | 257 | return |
| 255 | 258 | } |
| 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) | |
| 257 | 265 | name := repo.Name + "-" + safe |
| 258 | 266 | file := name + "." + format |
| 259 | 267 | 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 | ||
| 270 | 278 | } |
| 271 | 279 | w.Header().Set("ETag", `"`+commit+"-"+format+`"`) |
| 272 | 280 | 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") | |
| 274 | 282 | } |
| 275 | 283 | if r.Header.Get("If-None-Match") == `"`+commit+"-"+format+`"` { |
| 276 | 284 | w.WriteHeader(http.StatusNotModified) |
| 277 | 285 | return |
| 278 | 286 | } |
| 279 | 287 | 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})) | |
| 281 | 289 | writeArchive(ctx, w, g.Dir, format, name, commit) |
| 282 | 290 | } |
| 283 | 291 | |
| @@ -436,55 +444,99 @@ func (s *Server) userFeed(w http.ResponseWriter, r *http.Request, owner User) { | ||
| 436 | 444 | writeFeed(w, feed) |
| 437 | 445 | } |
| 438 | 446 | |
| 439 | -type activityCache struct { | |
| 440 | - sync.Mutex | |
| 441 | - entries map[int64]activityEntry | |
| 447 | +type repoHistory struct { | |
| 448 | + stamp time.Time | |
| 449 | + commits []historyCommit | |
| 442 | 450 | } |
| 443 | 451 | |
| 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 | |
| 447 | 456 | } |
| 448 | 457 | |
| 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{}} | |
| 450 | 463 | |
| 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}) | |
| 456 | 496 | } |
| 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 { | |
| 458 | 503 | counts := map[string]int{} |
| 459 | - email := strings.ToLower(s.store.VerifiedEmail(owner.ID)) | |
| 460 | 504 | org := s.store.IsOrg(owner.ID) |
| 505 | + email := strings.ToLower(s.store.VerifiedEmail(owner.ID)) | |
| 461 | 506 | if email == "" && !org { |
| 462 | 507 | return counts |
| 463 | 508 | } |
| 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{} | |
| 465 | 519 | repos, _ := s.store.PublicRepos("", "") |
| 466 | 520 | for _, repo := range repos { |
| 467 | 521 | if org && repo.Owner.ID != owner.ID { |
| 468 | 522 | continue |
| 469 | 523 | } |
| 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() | |
| 474 | 531 | } |
| 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] { | |
| 478 | 534 | continue |
| 479 | 535 | } |
| 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")]++ | |
| 483 | 538 | } |
| 484 | 539 | } |
| 485 | - activities.Lock() | |
| 486 | - activities.entries[owner.ID] = activityEntry{time.Now(), counts} | |
| 487 | - activities.Unlock() | |
| 488 | 540 | return counts |
| 489 | 541 | } |
| 490 | 542 | |
git.go modified
| @@ -672,7 +672,26 @@ func isBinary(data []byte) bool { | ||
| 672 | 672 | return bytes.IndexByte(head, 0) >= 0 || !utf8.Valid(head) |
| 673 | 673 | } |
| 674 | 674 | |
| 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 | + | |
| 675 | 689 | func cleanPath(p string) (string, bool) { |
| 690 | + for _, r := range p { | |
| 691 | + if r < 0x20 || r == 0x7f { | |
| 692 | + return "", false | |
| 693 | + } | |
| 694 | + } | |
| 676 | 695 | p = strings.Trim(p, "/") |
| 677 | 696 | if p == "" { |
| 678 | 697 | return "", true |
labels.go modified
| @@ -151,7 +151,7 @@ func (s *Store) Protection(repo int64, branch string) (Protection, bool) { | ||
| 151 | 151 | |
| 152 | 152 | func (s *Store) SetProtection(repo int64, branch string, requirePR bool, approvals int) error { |
| 153 | 153 | branch = strings.TrimSpace(branch) |
| 154 | - if branch == "" || strings.HasPrefix(branch, "-") || strings.Contains(branch, " ") { | |
| 154 | + if !validBranch(branch) { | |
| 155 | 155 | return errors.New("Give a branch name") |
| 156 | 156 | } |
| 157 | 157 | if approvals < 0 || approvals > 10 { |
limit.go modified
| @@ -68,3 +68,71 @@ func clientIP(r *http.Request) string { | ||
| 68 | 68 | } |
| 69 | 69 | return host |
| 70 | 70 | } |
| 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 { | ||
| 89 | 89 | return err |
| 90 | 90 | } |
| 91 | 91 | 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) | |
| 93 | 93 | if _, err := w.Write([]byte(msg)); err != nil { |
| 94 | 94 | return err |
| 95 | 95 | } |
| @@ -120,7 +120,7 @@ func (m *Mailer) domain() string { | ||
| 120 | 120 | } |
| 121 | 121 | |
| 122 | 122 | 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}) | |
| 124 | 124 | req, err := http.NewRequest("POST", m.API, bytes.NewReader(payload)) |
| 125 | 125 | if err != nil { |
| 126 | 126 | return err |
| @@ -138,3 +138,12 @@ func (m *Mailer) post(to, subject, body string) error { | ||
| 138 | 138 | } |
| 139 | 139 | return nil |
| 140 | 140 | } |
| 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) { | ||
| 460 | 460 | |
| 461 | 461 | func (s *Server) resolveSource(repo Repo, user *User, value string) (Repo, string, error) { |
| 462 | 462 | path, branch, ok := strings.Cut(value, ":") |
| 463 | - if !ok || branch == "" { | |
| 463 | + if !ok || !validBranch(branch) { | |
| 464 | 464 | return Repo{}, "", errors.New("Choose a source branch") |
| 465 | 465 | } |
| 466 | 466 | owner, name, ok := strings.Cut(strings.TrimPrefix(path, "~"), "/") |
| @@ -505,7 +505,7 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 505 | 505 | return |
| 506 | 506 | } |
| 507 | 507 | 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) { | |
| 509 | 509 | retry("No such target branch") |
| 510 | 510 | return |
| 511 | 511 | } |
shell.go modified
| @@ -60,7 +60,11 @@ func shell(root, name string) int { | ||
| 60 | 60 | } |
| 61 | 61 | repo, err := store.RepoByPath(owner, repoName) |
| 62 | 62 | 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 | |
| 64 | 68 | } |
| 65 | 69 | canWrite := store.CanWrite(repo, &user) |
| 66 | 70 | if repo.Private && !canWrite { |
| @@ -86,6 +90,10 @@ func shell(root, name string) int { | ||
| 86 | 90 | if p := os.Getenv("GIT_PROTOCOL"); p != "" { |
| 87 | 91 | env = append(env, "GIT_PROTOCOL="+p) |
| 88 | 92 | } |
| 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) | |
| 90 | 98 | return fail(err.Error()) |
| 91 | 99 | } |
smart.go modified
| @@ -36,7 +36,14 @@ func (s *Server) basicUser(r *http.Request) (User, bool) { | ||
| 36 | 36 | func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, rest string) { |
| 37 | 37 | repo, err := s.store.RepoByPath(owner.Name, name) |
| 38 | 38 | 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 | + } | |
| 40 | 47 | return |
| 41 | 48 | } |
| 42 | 49 | service := r.URL.Query().Get("service") |
| @@ -100,7 +107,14 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 100 | 107 | defer gz.Close() |
| 101 | 108 | body = gz |
| 102 | 109 | } |
| 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...) | |
| 104 | 118 | cmd.Stdin = body |
| 105 | 119 | cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")} |
| 106 | 120 | 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 ( | ||
| 7 | 7 | "encoding/hex" |
| 8 | 8 | "errors" |
| 9 | 9 | "fmt" |
| 10 | + "net/mail" | |
| 10 | 11 | "regexp" |
| 11 | 12 | "strings" |
| 12 | 13 | "time" |
| @@ -77,6 +78,7 @@ var migrations = []string{ | ||
| 77 | 78 | `alter table pulls add column draft integer not null default 0`, |
| 78 | 79 | `alter table repos add column featured integer not null default 0`, |
| 79 | 80 | `alter table users add column org integer not null default 0`, |
| 81 | + `alter table users add column admin integer not null default 0`, | |
| 80 | 82 | } |
| 81 | 83 | |
| 82 | 84 | const extraSchema = ` |
| @@ -269,20 +271,16 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 269 | 271 | if _, err := db.Exec(schema); err != nil { |
| 270 | 272 | return nil, err |
| 271 | 273 | } |
| 274 | + for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema} { | |
| 275 | + if _, err := db.Exec(ddl); err != nil { | |
| 276 | + return nil, err | |
| 277 | + } | |
| 278 | + } | |
| 272 | 279 | for _, m := range migrations { |
| 273 | 280 | if _, err := db.Exec(m); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| 274 | 281 | return nil, err |
| 275 | 282 | } |
| 276 | 283 | } |
| 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 | - } | |
| 286 | 284 | } |
| 287 | 285 | return &Store{db}, nil |
| 288 | 286 | } |
| @@ -929,7 +927,7 @@ func (s *Store) Account(user int64) Account { | ||
| 929 | 927 | |
| 930 | 928 | func (s *Store) SetEmail(user int64, email string) error { |
| 931 | 929 | email = strings.TrimSpace(strings.ToLower(email)) |
| 932 | - if email != "" && (!strings.Contains(email, "@") || len(email) > 200) { | |
| 930 | + if email != "" && !validEmail(email) { | |
| 933 | 931 | return errors.New("That does not look like an email address") |
| 934 | 932 | } |
| 935 | 933 | _, 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 { | ||
| 1076 | 1074 | func (r Repo) Path() string { |
| 1077 | 1075 | return fmt.Sprintf("~%s/%s", r.Owner.Name, r.Name) |
| 1078 | 1076 | } |
| 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 ( | ||
| 7 | 7 | "encoding/hex" |
| 8 | 8 | "errors" |
| 9 | 9 | "fmt" |
| 10 | + "html" | |
| 10 | 11 | "html/template" |
| 11 | 12 | "io" |
| 12 | 13 | "io/fs" |
| @@ -42,12 +43,13 @@ type Server struct { | ||
| 42 | 43 | mailer *Mailer |
| 43 | 44 | version string |
| 44 | 45 | limiter *limiter |
| 46 | + requests *buckets | |
| 47 | + heavy *buckets | |
| 45 | 48 | store *Store |
| 46 | 49 | root string |
| 47 | 50 | host string |
| 48 | 51 | name string |
| 49 | 52 | sshUser string |
| 50 | - open bool | |
| 51 | 53 | pages map[string]*template.Template |
| 52 | 54 | markdown goldmark.Markdown |
| 53 | 55 | policy *bluemonday.Policy |
| @@ -64,10 +66,13 @@ type page struct { | ||
| 64 | 66 | Error string |
| 65 | 67 | Math bool |
| 66 | 68 | Data any |
| 69 | + | |
| 70 | + Registration string | |
| 71 | + Admin bool | |
| 67 | 72 | } |
| 68 | 73 | |
| 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)} | |
| 71 | 76 | funcs := template.FuncMap{ |
| 72 | 77 | "short": func(h string) string { |
| 73 | 78 | if len(h) > 10 { |
| @@ -142,12 +147,16 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p | ||
| 142 | 147 | p.Name = s.name |
| 143 | 148 | p.SSHUser = s.sshUser |
| 144 | 149 | p.Version = s.version |
| 150 | + p.Registration = s.store.Registration() | |
| 145 | 151 | if d, ok := p.Data.(map[string]any); ok && d["File"] == true { |
| 146 | 152 | p.Fluid = true |
| 147 | 153 | } |
| 148 | 154 | if p.User == nil { |
| 149 | 155 | p.User = s.current(r) |
| 150 | 156 | } |
| 157 | + if p.User != nil { | |
| 158 | + p.Admin = s.store.IsAdmin(p.User.ID) | |
| 159 | + } | |
| 151 | 160 | t, ok := s.pages[name] |
| 152 | 161 | if !ok { |
| 153 | 162 | http.Error(w, "missing template "+name, 500) |
| @@ -227,9 +236,11 @@ func (s *Server) handler() http.Handler { | ||
| 227 | 236 | mux.HandleFunc("POST /settings/profile", s.profile) |
| 228 | 237 | mux.HandleFunc("POST /settings/avatar", s.avatar) |
| 229 | 238 | mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar) |
| 239 | + mux.HandleFunc("/admin", s.admin) | |
| 240 | + mux.HandleFunc("/orgs/new", s.newOrg) | |
| 230 | 241 | mux.HandleFunc("/", s.route) |
| 231 | 242 | cop := http.NewCrossOriginProtection() |
| 232 | - return cop.Handler(mux) | |
| 243 | + return secure(s.throttle(cop.Handler(mux))) | |
| 233 | 244 | } |
| 234 | 245 | |
| 235 | 246 | func (s *Server) index(w http.ResponseWriter, r *http.Request) { |
| @@ -301,17 +312,22 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | ||
| 301 | 312 | } |
| 302 | 313 | |
| 303 | 314 | 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")}}) | |
| 305 | 316 | } |
| 306 | 317 | |
| 307 | 318 | func (s *Server) register(w http.ResponseWriter, r *http.Request) { |
| 308 | 319 | invite := strings.TrimSpace(r.FormValue("invite")) |
| 309 | 320 | retry := func(code int, msg string) { |
| 310 | 321 | 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 | |
| 312 | 328 | } |
| 313 | 329 | ip := clientIP(r) |
| 314 | - if s.limiter.blocked(ip) { | |
| 330 | + if s.limiter.blocked(ip) || s.limiter.blocked("register:"+ip) { | |
| 315 | 331 | retry(429, "Too many attempts, try again in a few minutes") |
| 316 | 332 | return |
| 317 | 333 | } |
| @@ -320,7 +336,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 320 | 336 | retry(400, err.Error()) |
| 321 | 337 | return |
| 322 | 338 | } |
| 323 | - if !s.open && !s.store.UseInvite(invite) { | |
| 339 | + if mode == "invite" && !s.store.UseInvite(invite) { | |
| 324 | 340 | s.limiter.fail(ip) |
| 325 | 341 | retry(400, "That invite is invalid or already used") |
| 326 | 342 | return |
| @@ -330,6 +346,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 330 | 346 | retry(400, err.Error()) |
| 331 | 347 | return |
| 332 | 348 | } |
| 349 | + s.limiter.fail("register:" + ip) | |
| 333 | 350 | s.startSession(w, u) |
| 334 | 351 | http.Redirect(w, r, "/settings", http.StatusSeeOther) |
| 335 | 352 | } |
| @@ -836,7 +853,9 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 836 | 853 | } |
| 837 | 854 | repo, err := s.store.RepoByPath(owner.Name, name) |
| 838 | 855 | 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 | + } | |
| 840 | 859 | return |
| 841 | 860 | } |
| 842 | 861 | if !s.store.CanRead(repo, user) { |
| @@ -1046,7 +1065,7 @@ func (s *Server) renderMarkdownAt(src []byte, rawBase, treeBase string) template | ||
| 1046 | 1065 | if attr == "src" { |
| 1047 | 1066 | base = rawBase |
| 1048 | 1067 | } |
| 1049 | - return []byte(attr + `="` + base + target + `"`) | |
| 1068 | + return []byte(attr + `="` + html.EscapeString(escapePath(base)) + target + `"`) | |
| 1050 | 1069 | }) |
| 1051 | 1070 | } |
| 1052 | 1071 | return template.HTML(out) |
| @@ -1186,7 +1205,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str | ||
| 1186 | 1205 | return |
| 1187 | 1206 | } |
| 1188 | 1207 | 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") | |
| 1190 | 1209 | w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox") |
| 1191 | 1210 | http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) |
| 1192 | 1211 | return |
| @@ -1485,3 +1504,52 @@ func (s *Server) orgSettings(w http.ResponseWriter, r *http.Request, org User, a | ||
| 1485 | 1504 | s.render(w, r, "orgsettings", page{Title: "~" + org.Name + " settings", User: u, Error: r.URL.Query().Get("error"), |
| 1486 | 1505 | Data: map[string]any{"Org": org, "Profile": s.store.Profile(org.ID), "Members": people}}) |
| 1487 | 1506 | } |
| 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 | +} | |