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).