feat(basin): failure based rate limits and size caps
12 files changed, +83 −29
- README.md modified +6 −2
- avatar.go modified +2 −2
- git.go modified +9 −0
- highlight.go modified +3 −0
- limit.go modified +19 −9
- smart.go modified +8 −0
- static/logo.svg added +1 −0
- static/style.css modified +4 −2
- store.go modified +1 −1
- templates/index.html modified +1 −1
- templates/layout.html modified +2 −1
- web.go modified +27 −11
README.md modified
| @@ -1,4 +1,4 @@ | ||
| 1 | -forge is a small self hosted git server in Go modelled on the browsing side of sourcehut. It serves repositories over ssh and smart https, renders trees, blobs, logs, diffs, refs and READMEs as plain server side HTML with no JavaScript, and keeps users, keys, tokens and repository metadata in a single SQLite file. | |
| 1 | +forge is a small self hosted git server in Go modelled on the browsing side of sourcehut. It serves repositories over ssh and smart https, renders trees, blobs, logs, diffs, refs and READMEs as server side HTML, and keeps users, profiles, keys, tokens and repository metadata in a single SQLite file. Source is highlighted with chroma, Markdown is rendered with goldmark and sanitised with bluemonday, and LaTeX in Markdown is typeset in the browser by a bundled KaTeX, which is the only JavaScript a reader ever loads. | |
| 2 | 2 | |
| 3 | 3 | ## Build |
| 4 | 4 | |
| @@ -27,6 +27,10 @@ Pushes over ssh reuse the host sshd. Point `AuthorizedKeysCommand` for the `git` | ||
| 27 | 27 | |
| 28 | 28 | Anonymous clients may fetch public repositories. Private repositories and all pushes require basic auth with either the account password or an access token created in settings. Token hashes are stored as SHA256, passwords as bcrypt. The web forms rely on the standard library cross origin protection, so a POST without a same origin `Sec-Fetch-Site` or `Origin` header from a browser is refused while non browser git clients pass. |
| 29 | 29 | |
| 30 | +## Profiles | |
| 31 | + | |
| 32 | +Each user has a bio, location, workplace, website and an avatar. Avatar uploads are decoded on the server, cropped to the square the settings page chose with a small canvas script, resampled to 256 pixels and stored as PNG, so the served image never contains the original file's metadata or dimensions. Without JavaScript the crop falls back to the centred square. | |
| 33 | + | |
| 30 | 34 | ## Limitations |
| 31 | 35 | |
| 32 | -There is no syntax highlighting, no search, no per directory last commit column and no collaborators beyond the owner. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet. Markdown READMEs are rendered with goldmark and sanitised with bluemonday, other README formats are shown preformatted. | |
| 36 | +There is no search, no collaborators beyond the owner and no disk quota, so a pushed repository is bounded only by the nginx body limit and free space. Login, registration and https basic auth share a per address failure counter of ten misses per five minutes, which nginx must feed through `X-Forwarded-For`. Blobs over 1 MiB are not rendered and over 64 MiB are not served raw. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet. | |
avatar.go modified
| @@ -19,8 +19,8 @@ func cropAvatar(data []byte, x, y, size int) ([]byte, error) { | ||
| 19 | 19 | if err != nil { |
| 20 | 20 | return nil, errors.New("Avatar must be a PNG, JPEG, GIF or WebP image") |
| 21 | 21 | } |
| 22 | - if cfg.Width*cfg.Height > 40_000_000 { | |
| 23 | - return nil, errors.New("Avatar image is too large, keep it under 40 megapixels") | |
| 22 | + if cfg.Width*cfg.Height > 25_000_000 { | |
| 23 | + return nil, errors.New("Avatar image is too large, keep it under 25 megapixels") | |
| 24 | 24 | } |
| 25 | 25 | img, _, err := image.Decode(bytes.NewReader(data)) |
| 26 | 26 | if err != nil { |
git.go modified
| @@ -238,6 +238,15 @@ func (g Git) Blob(commit, filePath string) ([]byte, error) { | ||
| 238 | 238 | return out, nil |
| 239 | 239 | } |
| 240 | 240 | |
| 241 | +func (g Git) Size(commit, filePath string) int64 { | |
| 242 | + out, err := g.run(nil, "cat-file", "-s", commit+":"+filePath) | |
| 243 | + if err != nil { | |
| 244 | + return 0 | |
| 245 | + } | |
| 246 | + n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64) | |
| 247 | + return n | |
| 248 | +} | |
| 249 | + | |
| 241 | 250 | func (g Git) Kind(commit, filePath string) string { |
| 242 | 251 | out, err := g.run(nil, "cat-file", "-t", commit+":"+filePath) |
| 243 | 252 | if err != nil { |
highlight.go modified
| @@ -22,6 +22,9 @@ var formatter = html.New( | ||
| 22 | 22 | var highlightStyle = styles.Get("github") |
| 23 | 23 | |
| 24 | 24 | func highlight(name string, content []byte) (template.HTML, bool) { |
| 25 | + if len(content) > 256<<10 { | |
| 26 | + return "", false | |
| 27 | + } | |
| 25 | 28 | lexer := lexers.Match(filepath.Base(name)) |
| 26 | 29 | if lexer == nil { |
| 27 | 30 | lexer = lexers.Analyse(string(content)) |
limit.go modified
| @@ -19,10 +19,7 @@ func newLimiter(max int, window time.Duration) *limiter { | ||
| 19 | 19 | return &limiter{hits: map[string][]time.Time{}, max: max, window: window} |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | -func (l *limiter) allow(key string) bool { | |
| 23 | - l.mu.Lock() | |
| 24 | - defer l.mu.Unlock() | |
| 25 | - now := time.Now() | |
| 22 | +func (l *limiter) prune(key string, now time.Time) []time.Time { | |
| 26 | 23 | cutoff := now.Add(-l.window) |
| 27 | 24 | kept := l.hits[key][:0] |
| 28 | 25 | for _, t := range l.hits[key] { |
| @@ -30,19 +27,32 @@ func (l *limiter) allow(key string) bool { | ||
| 30 | 27 | kept = append(kept, t) |
| 31 | 28 | } |
| 32 | 29 | } |
| 33 | - if len(kept) >= l.max { | |
| 30 | + if len(kept) == 0 { | |
| 31 | + delete(l.hits, key) | |
| 32 | + } else { | |
| 34 | 33 | l.hits[key] = kept |
| 35 | - return false | |
| 36 | 34 | } |
| 37 | - l.hits[key] = append(kept, now) | |
| 38 | 35 | if len(l.hits) > 10000 { |
| 39 | 36 | for k, v := range l.hits { |
| 40 | - if len(v) == 0 || !v[len(v)-1].After(cutoff) { | |
| 37 | + if !v[len(v)-1].After(cutoff) { | |
| 41 | 38 | delete(l.hits, k) |
| 42 | 39 | } |
| 43 | 40 | } |
| 44 | 41 | } |
| 45 | - return true | |
| 42 | + return kept | |
| 43 | +} | |
| 44 | + | |
| 45 | +func (l *limiter) blocked(key string) bool { | |
| 46 | + l.mu.Lock() | |
| 47 | + defer l.mu.Unlock() | |
| 48 | + return len(l.prune(key, time.Now())) >= l.max | |
| 49 | +} | |
| 50 | + | |
| 51 | +func (l *limiter) fail(key string) { | |
| 52 | + l.mu.Lock() | |
| 53 | + defer l.mu.Unlock() | |
| 54 | + now := time.Now() | |
| 55 | + l.hits[key] = append(l.prune(key, now), now) | |
| 46 | 56 | } |
| 47 | 57 | |
| 48 | 58 | func clientIP(r *http.Request) string { |
smart.go modified
| @@ -48,8 +48,16 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name, | ||
| 48 | 48 | } |
| 49 | 49 | needAuth := service == "git-receive-pack" || repo.Private |
| 50 | 50 | if needAuth { |
| 51 | + ip := clientIP(r) | |
| 52 | + if s.limiter.blocked(ip) { | |
| 53 | + http.Error(w, "too many failed attempts", 429) | |
| 54 | + return | |
| 55 | + } | |
| 51 | 56 | u, ok := s.basicUser(r) |
| 52 | 57 | if !ok || u.ID != repo.Owner.ID { |
| 58 | + if _, _, given := r.BasicAuth(); given { | |
| 59 | + s.limiter.fail(ip) | |
| 60 | + } | |
| 53 | 61 | w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`) |
| 54 | 62 | http.Error(w, "authentication required", 401) |
| 55 | 63 | return |
static/logo.svg added
| @@ -0,0 +1 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><rect width="32" height="32" rx="7" fill="#212529"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11 9v14"/><path d="M11 16c0-3.5 2.5-5.5 7-5.5h2"/></g><g fill="#fff"><circle cx="11" cy="8" r="2.6"/><circle cx="11" cy="24" r="2.6"/><circle cx="21" cy="10.5" r="2.6"/></g></svg> | |
static/style.css modified
| @@ -18,7 +18,8 @@ h2:first-child,h3:first-child{margin-top:0} | ||
| 18 | 18 | .notice{border:1px solid var(--add);border-radius:3px;padding:.4rem .6rem;background:var(--addbg)} |
| 19 | 19 | header{background:var(--soft);border-bottom:1px solid var(--line)} |
| 20 | 20 | nav.site{max-width:1100px;margin:0 auto;padding:.55rem 1rem;display:flex;gap:1.2rem;align-items:center;flex-wrap:wrap} |
| 21 | -nav.site .brand{font-weight:700;color:var(--fg)} | |
| 21 | +nav.site .brand{font-weight:700;color:var(--fg);display:inline-flex;align-items:center;gap:.5rem} | |
| 22 | +nav.site .brand svg{display:block} | |
| 22 | 23 | nav.site .spacer{flex:1} |
| 23 | 24 | nav.site a{color:var(--fg)} |
| 24 | 25 | nav.site form{display:inline} |
| @@ -32,7 +33,8 @@ nav.tabs a{color:var(--fg);padding:.4rem .9rem;border:1px solid transparent;bord | ||
| 32 | 33 | nav.tabs a:hover{text-decoration:none;background:#fff;border-color:var(--line)} |
| 33 | 34 | main{max-width:1100px;margin:0 auto;padding:1.2rem 1rem 3rem} |
| 34 | 35 | .hero{padding:1rem 0 1.5rem;border-bottom:1px solid var(--line);margin-bottom:1rem;max-width:44rem} |
| 35 | -.hero h1{font-size:1.8rem} | |
| 36 | +.hero h1{font-size:1.8rem;display:flex;align-items:center;gap:.6rem} | |
| 37 | +.hero h1 img{width:40px;height:40px} | |
| 36 | 38 | .hero p{margin:.4rem 0} |
| 37 | 39 | .row{display:flex;justify-content:space-between;align-items:baseline} |
| 38 | 40 | .row h2{margin-top:0} |
store.go modified
| @@ -233,7 +233,7 @@ func (s *Store) CreateSession(user int64) (string, error) { | ||
| 233 | 233 | |
| 234 | 234 | func (s *Store) SessionUser(id string) (User, error) { |
| 235 | 235 | var user int64 |
| 236 | - err := s.db.QueryRow(`select user from sessions where id = ?`, id).Scan(&user) | |
| 236 | + err := s.db.QueryRow(`select user from sessions where id = ? and created > ?`, id, time.Now().Add(-90*24*time.Hour).Unix()).Scan(&user) | |
| 237 | 237 | if err != nil { |
| 238 | 238 | return User{}, ErrNotFound |
| 239 | 239 | } |
templates/index.html modified
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | {{template "repolist" .Data.Repos}} |
| 8 | 8 | {{else}} |
| 9 | 9 | <section class="hero"> |
| 10 | -<h1>{{.Host}}</h1> | |
| 10 | +<h1><img src="/static/logo.svg" alt="">{{.Host}}</h1> | |
| 11 | 11 | <p>A small git host. Push over SSH or HTTPS, browse trees, logs and diffs, and read READMEs with Markdown and LaTeX rendering. No JavaScript is needed to use it.</p> |
| 12 | 12 | <p class="muted">{{.Data.Public}} public repositories from {{.Data.Users}} users.</p> |
| 13 | 13 | <p><a class="button" href="/login">Log in</a> <a class="button secondary" href="/register">Register</a></p> |
templates/layout.html modified
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | 6 | <title>{{.Title}}</title> |
| 7 | +<link rel="icon" href="/static/logo.svg" type="image/svg+xml"> | |
| 7 | 8 | <link rel="stylesheet" href="/static/style.css"> |
| 8 | 9 | <link rel="stylesheet" href="/static/highlight.css"> |
| 9 | 10 | {{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css"> |
| @@ -14,7 +15,7 @@ | ||
| 14 | 15 | <body> |
| 15 | 16 | <header> |
| 16 | 17 | <nav class="site"> |
| 17 | -<a class="brand" href="/">{{.Host}}</a> | |
| 18 | +<a class="brand" href="/"><svg viewBox="0 0 32 32" width="22" height="22" aria-hidden="true"><rect width="32" height="32" rx="7" fill="currentColor"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11 9v14"/><path d="M11 16c0-3.5 2.5-5.5 7-5.5h2"/></g><g fill="#fff"><circle cx="11" cy="8" r="2.6"/><circle cx="11" cy="24" r="2.6"/><circle cx="21" cy="10.5" r="2.6"/></g></svg>{{.Host}}</a> | |
| 18 | 19 | <span class="spacer"></span> |
| 19 | 20 | {{if .User}}<a href="/~{{.User.Name}}">~{{.User.Name}}</a><a href="/create">New repository</a><a href="/settings">Settings</a><form method="post" action="/logout"><button>Log out</button></form>{{else}}<a href="/login">Log in</a><a href="/register">Register</a>{{end}} |
| 20 | 21 | </nav> |
web.go modified
| @@ -177,13 +177,15 @@ func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) { | ||
| 177 | 177 | } |
| 178 | 178 | |
| 179 | 179 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { |
| 180 | - if !s.limiter.allow(clientIP(r)) { | |
| 180 | + ip := clientIP(r) | |
| 181 | + if s.limiter.blocked(ip) { | |
| 181 | 182 | w.WriteHeader(429) |
| 182 | 183 | s.render(w, r, "login", page{Title: "Log in", Error: "Too many attempts, try again in a few minutes"}) |
| 183 | 184 | return |
| 184 | 185 | } |
| 185 | 186 | u, ok := s.store.CheckPassword(strings.TrimSpace(r.FormValue("name")), r.FormValue("password")) |
| 186 | 187 | if !ok { |
| 188 | + s.limiter.fail(ip) | |
| 187 | 189 | w.WriteHeader(401) |
| 188 | 190 | s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"}) |
| 189 | 191 | return |
| @@ -218,7 +220,8 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 218 | 220 | w.WriteHeader(code) |
| 219 | 221 | s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Open": s.open, "Invite": invite}}) |
| 220 | 222 | } |
| 221 | - if !s.limiter.allow(clientIP(r)) { | |
| 223 | + ip := clientIP(r) | |
| 224 | + if s.limiter.blocked(ip) { | |
| 222 | 225 | retry(429, "Too many attempts, try again in a few minutes") |
| 223 | 226 | return |
| 224 | 227 | } |
| @@ -228,6 +231,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { | ||
| 228 | 231 | return |
| 229 | 232 | } |
| 230 | 233 | if !s.open && !s.store.UseInvite(invite) { |
| 234 | + s.limiter.fail(ip) | |
| 231 | 235 | retry(400, "That invite is invalid or already used") |
| 232 | 236 | return |
| 233 | 237 | } |
| @@ -424,7 +428,8 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) { | ||
| 424 | 428 | return |
| 425 | 429 | } |
| 426 | 430 | if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok { |
| 427 | - http.Redirect(w, r, "/settings?error=current+password+is+wrong", http.StatusSeeOther) | |
| 431 | + s.limiter.fail(clientIP(r)) | |
| 432 | + http.Redirect(w, r, "/settings?error=Current+password+is+wrong", http.StatusSeeOther) | |
| 428 | 433 | return |
| 429 | 434 | } |
| 430 | 435 | if err := s.store.SetPassword(u.ID, r.FormValue("password")); err != nil { |
| @@ -563,6 +568,10 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 563 | 568 | |
| 564 | 569 | var displayMath = regexp.MustCompile(`(?m)^( {0,3})\$\$([^$\n]+)\$\$[ \t]*$`) |
| 565 | 570 | |
| 571 | +const maxRender = 1 << 20 | |
| 572 | + | |
| 573 | +const maxRaw = 64 << 20 | |
| 574 | + | |
| 566 | 575 | func (s *Server) renderMarkdown(src []byte) template.HTML { |
| 567 | 576 | src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$")) |
| 568 | 577 | var buf bytes.Buffer |
| @@ -629,16 +638,19 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 629 | 638 | data["Last"] = g.LastCommits(rp.Commit, filePath, names) |
| 630 | 639 | s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data}) |
| 631 | 640 | case "blob": |
| 632 | - content, err := g.Blob(rp.Commit, filePath) | |
| 633 | - if err != nil { | |
| 634 | - s.fail(w, r, 404, "No such path") | |
| 635 | - return | |
| 641 | + size := g.Size(rp.Commit, filePath) | |
| 642 | + data["Size"] = size | |
| 643 | + var content []byte | |
| 644 | + if size <= maxRender { | |
| 645 | + if content, err = g.Blob(rp.Commit, filePath); err != nil { | |
| 646 | + s.fail(w, r, 404, "No such path") | |
| 647 | + return | |
| 648 | + } | |
| 636 | 649 | } |
| 637 | - data["Size"] = int64(len(content)) | |
| 638 | - if isBinary(content) { | |
| 639 | - data["Binary"] = true | |
| 640 | - } else if len(content) > 1<<20 { | |
| 650 | + if size > maxRender { | |
| 641 | 651 | data["Large"] = true |
| 652 | + } else if isBinary(content) { | |
| 653 | + data["Binary"] = true | |
| 642 | 654 | } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" { |
| 643 | 655 | data["Markdown"] = s.renderMarkdown(content) |
| 644 | 656 | } else if h, ok := highlight(filePath, content); ok { |
| @@ -669,6 +681,10 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str | ||
| 669 | 681 | s.fail(w, r, 404, "No such ref") |
| 670 | 682 | return |
| 671 | 683 | } |
| 684 | + if g.Size(commit, filePath) > maxRaw { | |
| 685 | + s.fail(w, r, 413, "File too large to serve") | |
| 686 | + return | |
| 687 | + } | |
| 672 | 688 | content, err := g.Blob(commit, filePath) |
| 673 | 689 | if err != nil { |
| 674 | 690 | s.fail(w, r, 404, "No such path") |