Gitfed
bastien-mrq/gitfed/ Commits/ 6eb3faf

Anonymous read-only HTTPS clone for public repos

Adds git's smart-HTTP protocol (git-upload-pack only) so public repos can be cloned with git clone https://<domain>/<owner>/<repo>.git and no account, no SSH key, no certificate. Deliberately narrow: - Read-only. There is no git-receive-pack route over HTTP at all — pushing still only ever works over SSH, authenticated by key or federated certificate. - repo.Public is re-checked on every request, never cached; a private or nonexistent repo 404s identically, matching the web browser's existing canView behavior. - Routed at the site root so clone URLs look like the GitHub/GitLab convention people expect, rather than under a dedicated prefix. The upload-pack POST is exempted from the same-origin CSRF check (real git clients never send Origin/Referer) — safe since it carries no session cookie and mutates no state. Repo pages now show both the HTTPS and SSH clone URLs for public repos. Updated ARCHITECTURE.md/.en.md and HOW_IT_WORKS.md/.en.md, which both described gitfed as SSH-only, plus a dated addendum to docs/security/AUDIT.md rather than editing its original findings.

bastien-mrq 2026-07-28 19:51 commit 6eb3fafb6f2955a13e034b9a8866333b5c65440e parent 6143712fe6b4830764d7a38b23551da179dee658
10 files changed +286 −5
A cmd/gitfed-web/handlers_git_http.go +143 −0
M cmd/gitfed-web/handlers_repo.go +10 −1
M cmd/gitfed-web/render.go +1 −0
M cmd/gitfed-web/routes.go +7 −0
M cmd/gitfed-web/security_headers.go +9 −0
M docs/ARCHITECTURE.en.md +28 −2
M docs/ARCHITECTURE.md +29 −2
M docs/HOW_IT_WORKS.en.md +17 −0
M docs/HOW_IT_WORKS.md +18 −0
M docs/security/AUDIT.md +24 −0
cmd/gitfed-web/handlers_git_http.go
diff --git a/cmd/gitfed-web/handlers_git_http.go b/cmd/gitfed-web/handlers_git_http.go new file mode 100644 index 0000000..1231255 --- /dev/null +++ b/cmd/gitfed-web/handlers_git_http.go @@ -0,0 +1,143 @@ +package main + +import ( + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "os/exec" + "strings" + "time" +) + +// This file implements just enough of git's "smart HTTP" transport +// (https://git-scm.com/docs/http-protocol) to let anyone run +// `git clone https://<domain>/<owner>/<repo>.git` against a public repo +// with no account, no SSH key, nothing. It is deliberately narrow: +// +// - Read-only. Only git-upload-pack (clone/fetch) is served; there is no +// git-receive-pack over HTTP, ever. Pushing still only works over SSH, +// authenticated by key or federated certificate (see HOW_IT_WORKS.md). +// - Public repos only. repo.Public is re-checked on every request — nothing +// is cached across requests, and a repo that's private (or doesn't +// exist) 404s identically, the same "don't confirm what you can't see" +// rule the web repo browser already follows (see canView). +// +// Routed at the site root ("/{owner}/{repo}.git/...") rather than under a +// "/git/" prefix so clone URLs look exactly like what people expect from +// GitHub/GitLab/Codeberg. Real git HTTP clients always append "/info/refs" +// or "/git-upload-pack" to whatever base URL they were given, so the actual +// route just has to capture "everything else" and split the suffix back +// off — see gitHTTPRepoName. Go's ServeMux always prefers a more specific +// literal route (/dashboard, /r/{repo...}, etc.) over this catch-all, so it +// can't shadow any other page. The only real edge case: a local username +// that happens to collide with another top-level route name (e.g. someone +// named "settings") would have its HTTPS clone URL 404 — SSH cloning and +// the web browser are unaffected either way. + +func (s *server) handleGitInfoRefs(w http.ResponseWriter, r *http.Request) { + repoName, ok := gitHTTPRepoName(r.PathValue("gitpath"), "/info/refs") + if !ok { + http.NotFound(w, r) + return + } + if r.URL.Query().Get("service") != "git-upload-pack" { + // No dumb-HTTP fallback and no receive-pack advertisement — smart + // upload-pack only. + http.NotFound(w, r) + return + } + + repo, err := s.ops.GetRepo(repoName) + if err != nil || !repo.Public { + http.NotFound(w, r) + return + } + extendWriteDeadline(w) + + w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") + w.Header().Set("Cache-Control", "no-cache") + writePktLine(w, "# service=git-upload-pack\n") + writeFlushPkt(w) + + cmd := exec.Command("git", "upload-pack", "--stateless-rpc", "--advertise-refs", repo.Path) + cmd.Stdout = w + if err := cmd.Run(); err != nil { + log.Printf("gitfed-web: git-http info/refs %s: %v", repoName, err) + } +} + +func (s *server) handleGitUploadPack(w http.ResponseWriter, r *http.Request) { + repoName, ok := gitHTTPRepoName(r.PathValue("gitpath"), "/git-upload-pack") + if !ok { + http.NotFound(w, r) + return + } + + repo, err := s.ops.GetRepo(repoName) + if err != nil || !repo.Public { + http.NotFound(w, r) + return + } + extendWriteDeadline(w) + + body := r.Body + if r.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(r.Body) + if err != nil { + http.Error(w, "bad gzip body", http.StatusBadRequest) + return + } + defer gz.Close() + body = gz + } + + w.Header().Set("Content-Type", "application/x-git-upload-pack-result") + w.Header().Set("Cache-Control", "no-cache") + + cmd := exec.Command("git", "upload-pack", "--stateless-rpc", repo.Path) + cmd.Stdin = body + cmd.Stdout = w + if err := cmd.Run(); err != nil { + log.Printf("gitfed-web: git-http upload-pack %s: %v", repoName, err) + } +} + +// gitHTTPRepoName strips the request-verb suffix git's HTTP client always +// appends, plus the conventional ".git" extension, from the captured +// wildcard path, giving back a store.Repo.Name to look up. ok is false when +// the path doesn't end in the expected suffix at all — i.e. this request +// isn't a git-smart-HTTP request in the first place. +func gitHTTPRepoName(path, suffix string) (string, bool) { + if !strings.HasSuffix(path, suffix) { + return "", false + } + repo := strings.TrimSuffix(strings.TrimSuffix(path, suffix), ".git") + if repo == "" { + return "", false + } + return repo, true +} + +// extendWriteDeadline lifts the server-wide WriteTimeout (tuned short, for +// the rest of the app's small HTML/JSON responses) for this one response — +// a pack transfer for even a modest repo can easily take longer than that +// over a slow connection. +func extendWriteDeadline(w http.ResponseWriter) { + if rc := http.NewResponseController(w); rc != nil { + _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Minute)) + } +} + +// writePktLine and writeFlushPkt implement just enough of git's pkt-line +// framing (see Documentation/technical/protocol-common.txt in git's own +// source) for the one line the smart-HTTP info/refs response needs before +// git's own --advertise-refs output. +func writePktLine(w io.Writer, s string) { + fmt.Fprintf(w, "%04x%s", len(s)+4, s) +} + +func writeFlushPkt(w io.Writer) { + io.WriteString(w, "0000") +}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 7c9caca..e05bc3f 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -87,7 +87,15 @@ var repoTpl = newTpl("repo", ` <div class="gf-action-bar"> {{if .Branch}}<span class="gf-btn">{{icon "branch"}} {{.Branch}}</span>{{end}} + {{if .Repo.Public}} <div class="gf-clone-url"> + <span class="gf-clone-label">HTTPS</span> + <code>{{.HTTPSCloneURL}}</code> + <button type="button" class="linklike" data-copy="{{.HTTPSCloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button> + </div> + {{end}} + <div class="gf-clone-url"> + <span class="gf-clone-label">SSH</span> <code>{{.CloneURL}}</code> <button type="button" class="linklike" data-copy="{{.CloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button> </div> @@ -224,6 +232,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { Repo store.Repo Domain string CloneURL string + HTTPSCloneURL string Branch string Lang string Crumbs []crumb @@ -238,7 +247,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { CanAdminister bool Flash template.HTML }{ - repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", branch, string(lang), + repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch, string(lang), breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found, tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r), })
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 6cac065..2192183 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -315,6 +315,7 @@ const shellHeadSrc = `<!doctype html> background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.4rem 0.7rem; display: flex; align-items: center; gap: 0.5rem; overflow: hidden; } + .gf-clone-label { flex-shrink: 0; font-family: var(--mono); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.03em; color: var(--text-faint); } .gf-clone-url code { background: none; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gf-clone-url button { margin: 0 0 0 auto; padding: 0; background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; font-size: 0.95rem; } .gf-clone-url button:hover { color: var(--text); }
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 158c52f..32f4e2f 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -16,6 +16,13 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /explore", s.handleExplore) mux.HandleFunc("GET /lang/{lang}", s.handleSetLang) + // Anonymous read-only git-over-HTTPS for public repos (clone/fetch + // only — no receive-pack). Deliberately at the site root, not under a + // prefix, so clone URLs look like "https://domain/owner/repo.git" — + // see the long comment in handlers_git_http.go for why that's safe. + mux.HandleFunc("GET /{gitpath...}", s.handleGitInfoRefs) + mux.HandleFunc("POST /{gitpath...}", s.handleGitUploadPack) + // Self-service — any logged-in user, scoped to their own stuff via // CheckAccess inside the handlers. // Repo sub-actions get their own path prefixes rather than a suffix
cmd/gitfed-web/security_headers.go
diff --git a/cmd/gitfed-web/security_headers.go b/cmd/gitfed-web/security_headers.go index 07fc25b..5820ac1 100644 --- a/cmd/gitfed-web/security_headers.go +++ b/cmd/gitfed-web/security_headers.go @@ -93,6 +93,15 @@ func (s *server) sameOriginPOST(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } + // The git-upload-pack POST (see handlers_git_http.go) is a real git + // client, not a browser — it never sends Origin/Referer, so it + // would always be refused otherwise. Exempting it isn't a CSRF + // hole: it carries no session cookie, mutates no state, and only + // ever reads a repo already re-checked as public. + if strings.HasSuffix(r.URL.Path, "/git-upload-pack") { + next.ServeHTTP(w, r) + return + } if !sameOrigin(r) { http.Error(w, "cross-origin request refused", http.StatusForbidden) return
docs/ARCHITECTURE.en.md
diff --git a/docs/ARCHITECTURE.en.md b/docs/ARCHITECTURE.en.md index c863c8a..9b86e03 100644 --- a/docs/ARCHITECTURE.en.md +++ b/docs/ARCHITECTURE.en.md @@ -127,8 +127,9 @@ account) is in [`deploy/k8s/README.md`](../deploy/k8s/README.md). See [`docs/security/AUDIT.md`](security/AUDIT.md) *(French)* for the full detail, but in short, what's structurally in place: -- **No git-over-HTTP protocol** — everything goes through SSH, a single - network entry point for git itself. +- **Writes only ever happen over SSH** — `git-receive-pack` is never + exposed any other way, authenticated by key or federated certificate + only (see §7 for the one HTTP exception, strictly read-only). - **Anti-SSRF at connection time**, not just at domain-name validation (`internal/federation/wellknown.go`) — also protects against DNS-rebinding. @@ -138,3 +139,28 @@ detail, but in short, what's structurally in place: - **Immediate certificate revocation**, checked on every SSH authentication (`internal/ssh/server.go`). - **Rate limiting** on the web login, per account and per IP. + +## 7. Anonymous HTTPS clone for public repos + +As of 0.8.0, `cmd/gitfed-web/handlers_git_http.go` implements a small +slice of git's "smart HTTP" protocol (`git-upload-pack` only) so that +`git clone https://<domain>/<owner>/<repo>.git` works with no account and +no SSH key — see [`HOW_IT_WORKS.en.md`](HOW_IT_WORKS.en.md) §8 for the +user-facing explanation. Three structural guarantees: + +- **Read-only, full stop.** There simply is no `git-receive-pack` route + over HTTP — nothing to bypass, the code to write doesn't exist on this + path. +- **`repo.Public` is re-checked on every request**, never cached — a repo + that goes back to private instantly stops being cloneable over HTTP, and + a private or nonexistent repo returns the exact same 404, the same as + everywhere else in the app (`canView`). +- **Routed at the site root** (`/{owner}/{repo}.git/...`) rather than + under a dedicated prefix, so the clone URL looks like what people + expect. Go's mux always prefers more specific literal routes, so this + can't shadow any other page. + +This POST route is explicitly exempted from the same-origin CSRF check +(`sameOriginPOST`): a `git` client never sends an `Origin`/`Referer` +header. That's not a CSRF hole — the request carries no session cookie and +mutates no state.
docs/ARCHITECTURE.md
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ddcf073..ad56dcb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -129,8 +129,9 @@ est dans [`deploy/k8s/README.md`](../deploy/k8s/README.md). Voir [`docs/security/AUDIT.md`](security/AUDIT.md) pour le détail complet, mais en résumé, ce qui est en place structurellement : -- **Aucun protocole git en HTTP** — tout passe par SSH, une seule porte - d'entrée réseau pour le git lui-même. +- **Écriture uniquement par SSH** — `git-receive-pack` n'est jamais exposé + autrement, authentifié par clé ou certificat fédéré uniquement (voir §7 + pour la seule exception HTTP, strictement en lecture). - **Anti-SSRF au moment de la connexion**, pas seulement à la validation du nom de domaine (`internal/federation/wellknown.go`) — protège aussi contre le DNS-rebinding. @@ -140,3 +141,29 @@ mais en résumé, ce qui est en place structurellement : - **Révocation de certificat immédiate**, consultée à chaque authentification SSH (`internal/ssh/server.go`). - **Limitation de débit** sur le login web, par compte et par IP. + +## 7. Clone HTTPS anonyme pour les dépôts publics + +Depuis la version 0.8.0, `cmd/gitfed-web/handlers_git_http.go` implémente +une petite partie du protocole « smart HTTP » de git +(`git-upload-pack` uniquement) pour que +`git clone https://<domaine>/<owner>/<dépôt>.git` fonctionne sans compte ni +clé SSH — voir [`HOW_IT_WORKS.md`](HOW_IT_WORKS.md) §8 pour le détail +utilisateur. Trois garanties structurelles : + +- **Lecture seule, point final.** Il n'y a tout simplement aucune route + `git-receive-pack` en HTTP — pas de vérification à contourner, le code + pour écrire n'existe pas sur ce chemin. +- **`repo.Public` revérifié à chaque requête**, jamais mis en cache — un + dépôt qui redevient privé cesse instantanément d'être clonable en HTTP, + et un dépôt privé ou inexistant renvoie exactement le même 404, comme + partout ailleurs dans l'app (`canView`). +- **Route au niveau racine** (`/{owner}/{repo}.git/...`) plutôt que sous un + préfixe dédié, pour que l'URL de clone ressemble à ce que tout le monde + attend. Le mux de Go priorise toujours les routes littérales plus + spécifiques, donc ça ne peut pas masquer une autre page. + +Cette route POST est explicitement exemptée de la vérification +same-origin CSRF (`sameOriginPOST`) : un client `git` n'envoie jamais +d'en-tête `Origin`/`Referer`. Ce n'est pas une brèche CSRF — cette requête +ne porte aucun cookie de session et ne modifie aucun état.
docs/HOW_IT_WORKS.en.md
diff --git a/docs/HOW_IT_WORKS.en.md b/docs/HOW_IT_WORKS.en.md index 4ec8c43..4fcdc2b 100644 --- a/docs/HOW_IT_WORKS.en.md +++ b/docs/HOW_IT_WORKS.en.md @@ -140,3 +140,20 @@ instance.** There's no replication, no automatic mirroring between instances. Federation is only about identity — who's allowed to push or read — never about the data itself. If alice's instance goes down, her repo isn't available anywhere else unless it was cloned elsewhere first. + +## 8. Special case: cloning a public repo with no account + +Everything above describes *authenticated* access — required for any +private repo, and for any write, even to a public one. But a **public** +repo can also be cloned over HTTPS, with no certificate, no SSH key, no +account at all: + +```sh +git clone https://chez-moi.fr/alice/mon-projet.git +``` + +This is strictly a read shortcut, not a second authentication path: no +write is possible this way (there is no `git-receive-pack` route over +HTTP, literally none), and a repo that goes back to private instantly +stops being reachable this way. See [`ARCHITECTURE.en.md`](ARCHITECTURE.en.md) +§7 for the implementation detail.
docs/HOW_IT_WORKS.md
diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index 48b52f4..32913ae 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -146,3 +146,21 @@ La fédération ne concerne que l'identité — qui a le droit de pousser ou de lire — jamais les données elles-mêmes. Si l'instance d'alice tombe, son dépôt n'est disponible nulle part ailleurs tant qu'il n'a pas été cloné en dehors. + +## 8. Cas particulier : cloner un dépôt public sans compte + +Tout ce qui précède décrit l'accès *authentifié* — nécessaire pour tout +dépôt privé, et pour toute écriture, même sur un dépôt public. Mais un +dépôt **public** peut aussi être cloné en HTTPS, sans certificat, sans +clé SSH, sans compte du tout : + +```sh +git clone https://chez-moi.fr/alice/mon-projet.git +``` + +C'est strictement un raccourci de lecture, pas une deuxième voie +d'authentification : aucune écriture n'est possible par ce chemin (il n'y +a pas de route `git-receive-pack` en HTTP, littéralement aucune), et un +dépôt qui redevient privé cesse instantanément d'être accessible ainsi. +Voir [`ARCHITECTURE.md`](ARCHITECTURE.md) §7 pour le détail +d'implémentation.
docs/security/AUDIT.md
diff --git a/docs/security/AUDIT.md b/docs/security/AUDIT.md index 5ebe574..3f50899 100644 --- a/docs/security/AUDIT.md +++ b/docs/security/AUDIT.md @@ -212,3 +212,27 @@ Bon socle (non-root, `allowPrivilegeEscalation: false`, limites de ressources). ## 4. Note sur le CSRF L'absence de jetons CSRF explicites est **largement compensée** par `SameSite=Lax` : les navigateurs n'envoient pas le cookie de session sur une requête POST cross-site, ce qui protège tous les endpoints de mutation (qui sont en POST). Le seul endpoint mutateur en GET est `/lang/{lang}` (changement de langue, sans impact sécurité). Le risque résiduel est donc **faible**, mais l'ajout de jetons CSRF reste recommandé en défense en profondeur pour les actions admin (voir plan, tâche 8). + +--- + +## 5. Addendum (2026-07-29) — clone HTTPS anonyme + +Ce rapport date du commit `523f049` et reste exact pour ce qu'il décrit à +cette date. Depuis, une fonctionnalité délibérée a changé un fait qui +n'était pas remis en cause à l'époque, à savoir que gitfed n'exposait +strictement aucun protocole git en HTTP : la v0.8.0 ajoute un endpoint +HTTP `git-upload-pack` en lecture seule, réservé aux dépôts déjà publics, +pour permettre `git clone https://<domaine>/<owner>/<repo>.git` sans +compte ni clé SSH (demande explicite du mainteneur — voir +`ARCHITECTURE.md` §7 pour le détail d'implémentation et +`HOW_IT_WORKS.md` §8 pour l'explication utilisateur). + +Aucune des vulnérabilités listées ci-dessus n'est directement affectée : +la surface ajoutée est strictement lecture seule (aucune route +`git-receive-pack` n'existe côté HTTP), `repo.Public` est revérifié à +chaque requête sans mise en cache, et l'exemption CSRF qui l'accompagne +(un client `git` n'envoie jamais `Origin`/`Referer`) est sans risque +puisque la requête ne porte aucun cookie de session et ne modifie aucun +état. Un futur audit devrait néanmoins revalider ce chemin spécifiquement +(parsing du protocole git smart-HTTP, gestion des corps gzip, limites de +taille/temps).