Gitfed
bastien-mrq/gitfed/ Commits/ 3379541

Security hardening pre-prod + web i18n/landing pages

Implements the full fix plan from the pre-prod security audit (docs/security/AUDIT.md, FIX_PLAN.md). Also folds in the in-progress web i18n, landing and security pages that were already in the tree. Security fixes (T1-T12): - Login rate limiting per IP and per account (ratelimit.go) - Anti-SSRF on federation discovery: public-hostname validation + private/loopback/link-local/CGNAT IP block at dial time with IP pinning (anti DNS-rebinding); 1 MiB response cap - Security headers + strict CSP (inline script pinned by SHA-256 hash of the rendered script; inline onsubmit -> delegated data-confirm) - HTTP server timeouts on web and well-known servers - Certificate revocation: revoked bucket + CertChecker.IsRevoked; DeleteUser revokes principal, RemoveUserKey revokes key fingerprint; default cert TTL 48h -> 24h - gitfed-renew-cert: -host-key required unless -insecure - Constant-time password verification (dummy bcrypt on missing user) - Generic server errors (no internal detail leak) + same-origin POST check as CSRF defense-in-depth - Session TTL 30d -> 7d + hourly purge of expired sessions - bcrypt cost 10 -> 12 - Repo namespace enforcement + per-user repo quota - K8s hardening: readOnlyRootFilesystem, drop ALL caps, seccomp, NetworkPolicy egress restriction Tests: CSP hash vs rendered script, same-origin POST, SSRF domain validation + private-IP block, revocation lifecycle (store + admin).

bastien-mrq 2026-07-28 18:24 commit 33795416bf3362db2c56d09b4528b4a88d0333ca parent fe4234d65b70667df6b6d504e463e19462129713
41 files changed +2684 −290
M cmd/gitfed-renew-cert/main.go +16 −6
M cmd/gitfed-server/main.go +22 −1
M cmd/gitfed-web/handlers_admin.go +23 −20
M cmd/gitfed-web/handlers_admin_audit.go +14 −11
M cmd/gitfed-web/handlers_admin_trust.go +18 −14
M cmd/gitfed-web/handlers_admin_users.go +27 −21
M cmd/gitfed-web/handlers_auth.go +37 −14
M cmd/gitfed-web/handlers_changelog.go +3 −2
M cmd/gitfed-web/handlers_dashboard.go +60 −19
M cmd/gitfed-web/handlers_home.go +17 −15
A cmd/gitfed-web/handlers_landing.go +95 −0
M cmd/gitfed-web/handlers_repo.go +71 −61
M cmd/gitfed-web/handlers_search.go +13 −11
A cmd/gitfed-web/handlers_security.go +128 −0
M cmd/gitfed-web/handlers_settings.go +34 −28
A cmd/gitfed-web/lang.go +51 −0
M cmd/gitfed-web/main.go +28 −2
A cmd/gitfed-web/ratelimit.go +102 −0
M cmd/gitfed-web/render.go +180 −35
M cmd/gitfed-web/routes.go +4 −1
A cmd/gitfed-web/security_headers.go +117 −0
A cmd/gitfed-web/security_headers_test.go +78 −0
M cmd/gitfed-web/session.go +1 −1
M deploy/k8s/configmap.yaml +1 −1
M deploy/k8s/deployment.yaml +29 −0
A deploy/k8s/networkpolicy.yaml +45 −0
A docs/security/AUDIT.md +214 −0
A docs/security/FIX_PLAN.md +192 −0
M internal/admin/admin.go +80 −16
A internal/admin/revocation_test.go +89 −0
M internal/config/config.go +1 −1
A internal/federation/ssrf_test.go +63 −0
M internal/federation/wellknown.go +106 −8
A internal/i18n/i18n.go +55 −0
A internal/i18n/strings_en.go +247 −0
A internal/i18n/strings_fr.go +247 −0
M internal/ssh/server.go +20 −1
A internal/store/revocations.go +66 −0
A internal/store/revocations_test.go +59 −0
M internal/store/sessions.go +29 −0
M internal/store/store.go +2 −1
cmd/gitfed-renew-cert/main.go
diff --git a/cmd/gitfed-renew-cert/main.go b/cmd/gitfed-renew-cert/main.go index 9101803..35c75f8 100644 --- a/cmd/gitfed-renew-cert/main.go +++ b/cmd/gitfed-renew-cert/main.go @@ -20,13 +20,20 @@ func main() { host := flag.String("host", "", "home instance SSH address, e.g. instancea.example:22") keyPath := flag.String("key", "", "path to the private key to authenticate with") certPath := flag.String("cert", "", "path to read/write the certificate (default: <key>-cert.pub)") - hostKey := flag.String("host-key", "", "expected server host key (authorized_keys format); if empty, the host key is NOT verified") + hostKey := flag.String("host-key", "", "expected server host key (authorized_keys format); required unless -insecure is set") + insecure := flag.Bool("insecure", false, "skip host-key verification (UNSAFE: allows a man-in-the-middle to intercept renewal)") minTTL := flag.Duration("min-ttl", 6*time.Hour, "renew if less than this much validity remains") force := flag.Bool("force", false, "renew even if the current certificate is still comfortably valid") flag.Parse() if *host == "" || *keyPath == "" { - fmt.Fprintln(os.Stderr, "usage: gitfed-renew-cert -host <instance:port> -key <private-key-path> [-cert <path>] [-host-key <authorized_keys line>] [-min-ttl 6h]") + fmt.Fprintln(os.Stderr, "usage: gitfed-renew-cert -host <instance:port> -key <private-key-path> -host-key <authorized_keys line> [-cert <path>] [-min-ttl 6h]") + os.Exit(2) + } + // Fail closed: without a pinned host key we can't tell the real instance + // from a MITM. Verification must be explicitly waived with -insecure. + if *hostKey == "" && !*insecure { + fmt.Fprintln(os.Stderr, "gitfed-renew-cert: -host-key is required (or pass -insecure to skip verification, which is unsafe)") os.Exit(2) } if *certPath == "" { @@ -45,7 +52,7 @@ func main() { } } - if err := renew(*host, *keyPath, *certPath, *hostKey); err != nil { + if err := renew(*host, *keyPath, *certPath, *hostKey, *insecure); err != nil { fmt.Fprintln(os.Stderr, "gitfed-renew-cert:", err) os.Exit(1) } @@ -71,7 +78,7 @@ func certRemainingTTL(path string) (time.Duration, bool) { return time.Until(validBefore), true } -func renew(host, keyPath, certPath, hostKeyAuthorized string) error { +func renew(host, keyPath, certPath, hostKeyAuthorized string, insecure bool) error { keyData, err := os.ReadFile(keyPath) if err != nil { return fmt.Errorf("read private key: %w", err) @@ -81,15 +88,18 @@ func renew(host, keyPath, certPath, hostKeyAuthorized string) error { return fmt.Errorf("parse private key: %w", err) } - hostKeyCallback := gossh.InsecureIgnoreHostKey() + var hostKeyCallback gossh.HostKeyCallback if hostKeyAuthorized != "" { expected, _, _, _, err := gossh.ParseAuthorizedKey([]byte(hostKeyAuthorized)) if err != nil { return fmt.Errorf("parse -host-key: %w", err) } hostKeyCallback = gossh.FixedHostKey(expected) + } else if insecure { + fmt.Fprintln(os.Stderr, "warning: -insecure set, the server's host key will NOT be verified") + hostKeyCallback = gossh.InsecureIgnoreHostKey() } else { - fmt.Fprintln(os.Stderr, "warning: -host-key not set, the server's host key will not be verified") + return fmt.Errorf("no host key to verify against (this should have been caught earlier)") } client, err := gossh.Dial("tcp", host, &gossh.ClientConfig{
cmd/gitfed-server/main.go
diff --git a/cmd/gitfed-server/main.go b/cmd/gitfed-server/main.go index 19df0ac..d16a1fe 100644 --- a/cmd/gitfed-server/main.go +++ b/cmd/gitfed-server/main.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "os" + "time" "gitfed/internal/admin" "gitfed/internal/adminrpc" @@ -108,6 +109,18 @@ func run(configPath string) error { } } + // Sweep expired web sessions periodically — GetSession only evicts them + // lazily on access, so ones never looked up again would otherwise linger. + go func() { + for range time.Tick(time.Hour) { + if n, err := st.PurgeExpiredSessions(); err != nil { + log.Printf("session purge: %v", err) + } else if n > 0 { + log.Printf("purged %d expired session(s)", n) + } + } + }() + resolver := federation.NewResolver(st, cfg.Domain, cfg.InsecureFederation) adminOps := admin.New(st, resolver, cfg.Domain, cfg.ReposDir) @@ -123,8 +136,16 @@ func run(configPath string) error { handler := federation.Handler(cfg.Domain, cfg.Contact, version.Version, localCA) mux := http.NewServeMux() mux.Handle("/.well-known/gitfed.json", handler) + srv := &http.Server{ + Addr: cfg.ListenHTTP, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } log.Printf("gitfed well-known endpoint listening on %s", cfg.ListenHTTP) - if err := http.ListenAndServe(cfg.ListenHTTP, mux); err != nil { + if err := srv.ListenAndServe(); err != nil { log.Printf("well-known http server: %v", err) } }()
cmd/gitfed-web/handlers_admin.go
diff --git a/cmd/gitfed-web/handlers_admin.go b/cmd/gitfed-web/handlers_admin.go index 5bb3611..aa8d25f 100644 --- a/cmd/gitfed-web/handlers_admin.go +++ b/cmd/gitfed-web/handlers_admin.go @@ -5,54 +5,55 @@ import ( "html/template" "net/http" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var adminIndexTpl = template.Must(template.New("admin-index").Parse(` -<div class="gf-page-head"><h1>Admin</h1></div> +var adminIndexTpl = newTpl("admin-index", ` +<div class="gf-page-head"><h1>{{t .Lang "admin.title"}}</h1></div> <div class="gf-admin-grid"> <div class="gf-admin-card"> - <div class="top"><span class="l">Users</span></div> + <div class="top"><span class="l">{{t .Lang "admin.card_users"}}</span></div> <div class="n">{{.UserCount}}</div> - <p>{{.AdminCount}} admin{{if ne .AdminCount 1}}s{{end}}, {{.RegularCount}} regular</p> - <a class="go" href="/admin/users">Manage users →</a> + <p>{{t .Lang "admin.card_users_sub" .AdminCount .RegularCount}}</p> + <a class="go" href="/admin/users">{{t .Lang "admin.card_users_link"}} →</a> </div> <div class="gf-admin-card"> <div class="top"> - <span class="l">Trust store</span> - {{if .PendingCount}}<span class="badge pending">{{.PendingCount}} pending</span>{{end}} + <span class="l">{{t .Lang "admin.card_trust"}}</span> + {{if .PendingCount}}<span class="badge pending">{{.PendingCount}} {{t .Lang "admin.card_trust_pending"}}</span>{{end}} </div> <div class="n">{{.TrustCount}}</div> - <p>federated domains known</p> - <a class="go" href="/admin/trust">Review trust store →</a> + <p>{{t .Lang "admin.card_trust_sub"}}</p> + <a class="go" href="/admin/trust">{{t .Lang "admin.card_trust_link"}} →</a> </div> <div class="gf-admin-card"> - <div class="top"><span class="l">Audit log</span></div> + <div class="top"><span class="l">{{t .Lang "admin.card_audit"}}</span></div> <div class="n">{{.AuditCount}}</div> - <p>most recent events on record</p> - <a class="go" href="/admin/audit">View audit log →</a> + <p>{{t .Lang "admin.card_audit_sub"}}</p> + <a class="go" href="/admin/audit">{{t .Lang "admin.card_audit_link"}} →</a> </div> </div> {{if .RecentAudit}} -<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">Recent activity</h3> +<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">{{t .Lang "admin.recent_activity"}}</h3> <div class="gf-card gf-audit-mini"> {{range .RecentAudit}} <div class="gf-audit-row"> <span class="t">{{.Time.Local.Format "15:04:05"}}</span> <span class="who">{{.Principal}} · {{.Action}}{{if .Repo}} · {{.Repo}}{{end}}</span> - {{if .Allowed}}<span class="badge trusted">allow</span>{{else}}<span class="badge pending">deny</span>{{end}} + {{if .Allowed}}<span class="badge trusted">{{t $.Lang "admin.audit_allow"}}</span>{{else}}<span class="badge pending">{{t $.Lang "admin.audit_deny"}}</span>{{end}} </div> {{end}} </div> {{end}} -`)) +`) func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { users, err := s.ops.ListUsers() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } adminCount := 0 @@ -64,7 +65,7 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { trust, err := s.ops.ListTrustedCAs() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } pendingCount := 0 @@ -76,7 +77,7 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { audit, err := s.ops.ListAudit(200) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } recent := audit @@ -84,12 +85,14 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { recent = recent[:5] } + lang := s.lang(r) var buf bytes.Buffer _ = adminIndexTpl.Execute(&buf, struct { UserCount, AdminCount, RegularCount int TrustCount, PendingCount int AuditCount int + Lang string RecentAudit []store.AuditEvent - }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), recent}) - s.render(w, r, "Admin", "admin", template.HTML(buf.String())) + }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), string(lang), recent}) + s.render(w, r, i18n.T(lang, "admin.title"), "admin", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_admin_audit.go
diff --git a/cmd/gitfed-web/handlers_admin_audit.go b/cmd/gitfed-web/handlers_admin_audit.go index 5ee80b4..641f26c 100644 --- a/cmd/gitfed-web/handlers_admin_audit.go +++ b/cmd/gitfed-web/handlers_admin_audit.go @@ -5,39 +5,42 @@ import ( "html/template" "net/http" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var adminAuditTpl = template.Must(template.New("admin-audit").Parse(` -<p><a href="/admin">&larr; Admin</a></p> -<h1>Audit log</h1> +var adminAuditTpl = newTpl("admin-audit", ` +<p><a href="/admin">&larr; {{t .Lang "nav.admin"}}</a></p> +<h1>{{t .Lang "admin.audit_title"}}</h1> <table> -<tr><th>Time</th><th>Action</th><th>Principal</th><th>Repo/Domain</th><th>Result</th><th>Detail</th></tr> +<tr><th>{{t .Lang "admin.audit_col_time"}}</th><th>{{t .Lang "admin.audit_col_action"}}</th><th>{{t .Lang "admin.audit_col_principal"}}</th><th>{{t .Lang "admin.audit_col_repo_domain"}}</th><th>{{t .Lang "admin.audit_col_result"}}</th><th>{{t .Lang "admin.audit_col_detail"}}</th></tr> {{range .Events}} <tr> <td class="muted">{{.Time.Local.Format "2006-01-02 15:04:05"}}</td> <td>{{.Action}}</td> <td>{{.Principal}}</td> <td>{{if .Repo}}{{.Repo}}{{else}}{{.Domain}}{{end}}</td> - <td>{{if .Allowed}}<span class="badge trusted">allow</span>{{else}}<span class="badge pending">deny</span>{{end}}</td> + <td>{{if .Allowed}}<span class="badge trusted">{{t $.Lang "admin.audit_allow"}}</span>{{else}}<span class="badge pending">{{t $.Lang "admin.audit_deny"}}</span>{{end}}</td> <td class="muted">{{.Detail}}</td> </tr> {{else}} -<tr><td colspan="6" class="muted">No events recorded yet.</td></tr> +<tr><td colspan="6" class="muted">{{t .Lang "admin.audit_empty"}}</td></tr> {{end}} </table> -<p class="muted">Showing the most recent 200 events.</p> -`)) +<p class="muted">{{t .Lang "admin.audit_note"}}</p> +`) func (s *server) handleAdminAudit(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) events, err := s.ops.ListAudit(200) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } var buf bytes.Buffer _ = adminAuditTpl.Execute(&buf, struct { Events []store.AuditEvent - }{events}) - s.render(w, r, "Admin — Audit log", "admin", template.HTML(buf.String())) + Lang string + }{events, string(lang)}) + s.render(w, r, i18n.T(lang, "admin.audit_title"), "admin", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_admin_trust.go
diff --git a/cmd/gitfed-web/handlers_admin_trust.go b/cmd/gitfed-web/handlers_admin_trust.go index 67ba426..6de956d 100644 --- a/cmd/gitfed-web/handlers_admin_trust.go +++ b/cmd/gitfed-web/handlers_admin_trust.go @@ -5,55 +5,59 @@ import ( "html/template" "net/http" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var adminTrustTpl = template.Must(template.New("admin-trust").Parse(` +var adminTrustTpl = newTpl("admin-trust", ` {{.Flash}} -<p><a href="/admin">&larr; Admin</a></p> -<h1>Trust store</h1> +<p><a href="/admin">&larr; {{t .Lang "nav.admin"}}</a></p> +<h1>{{t .Lang "admin.trust_title"}}</h1> <table> -<tr><th>Domain</th><th>Status</th><th>First seen</th><th></th></tr> +<tr><th>{{t .Lang "admin.trust_col_domain"}}</th><th>{{t .Lang "admin.trust_col_status"}}</th><th>{{t .Lang "admin.trust_col_first_seen"}}</th><th></th></tr> {{range .Trust}} <tr> <td>{{.Domain}}</td> - <td><span class="badge {{.Status}}">{{.Status}}</span></td> + <td><span class="badge {{.Status}}">{{if eq (print .Status) "pending"}}{{t $.Lang "admin.trust_status_pending"}}{{else}}{{t $.Lang "admin.trust_status_trusted"}}{{end}}</span></td> <td class="muted">{{.FirstSeenAt.Local.Format "2006-01-02 15:04"}}</td> <td> - {{if eq .Status "pending"}} + {{if eq (print .Status) "pending"}} <form class="inline" method="post" action="/admin/trust/approve"> <input type="hidden" name="domain" value="{{.Domain}}"> - <button type="submit">Approve</button> + <button type="submit">{{t $.Lang "admin.approve"}}</button> </form> {{end}} </td> </tr> {{else}} -<tr><td colspan="4" class="muted">No remote domains discovered yet.</td></tr> +<tr><td colspan="4" class="muted">{{t .Lang "admin.trust_empty"}}</td></tr> {{end}} </table> -<p class="muted">Domains show up here automatically the first time a collaborator on a remote domain is granted access to a repo. Under the default whitelist policy they stay pending until approved here.</p> -`)) +<p class="muted">{{t .Lang "admin.trust_note"}}</p> +`) func (s *server) handleAdminTrustList(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) trust, err := s.ops.ListTrustedCAs() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } var buf bytes.Buffer _ = adminTrustTpl.Execute(&buf, struct { Trust []store.TrustedCA + Lang string Flash template.HTML - }{trust, flash(r)}) - s.render(w, r, "Admin — Trust store", "admin", template.HTML(buf.String())) + }{trust, string(lang), flash(r)}) + s.render(w, r, i18n.T(lang, "admin.trust_title"), "admin", template.HTML(buf.String())) } func (s *server) handleAdminTrustApprove(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) domain := r.FormValue("domain") if err := s.ops.ApproveDomain(domain); err != nil { redirectWithMsg(w, r, "/admin/trust", err.Error(), true) return } - redirectWithMsg(w, r, "/admin/trust", "approved "+domain, false) + redirectWithMsg(w, r, "/admin/trust", i18n.T(lang, "admin.msg_domain_approved", domain), false) }
cmd/gitfed-web/handlers_admin_users.go
diff --git a/cmd/gitfed-web/handlers_admin_users.go b/cmd/gitfed-web/handlers_admin_users.go index 683fc51..fb184ea 100644 --- a/cmd/gitfed-web/handlers_admin_users.go +++ b/cmd/gitfed-web/handlers_admin_users.go @@ -5,15 +5,16 @@ import ( "html/template" "net/http" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var adminUsersTpl = template.Must(template.New("admin-users").Parse(` +var adminUsersTpl = newTpl("admin-users", ` {{.Flash}} -<p><a href="/admin">&larr; Admin</a></p> -<h1>Users</h1> +<p><a href="/admin">&larr; {{t .Lang "nav.admin"}}</a></p> +<h1>{{t .Lang "admin.users_title"}}</h1> <table> -<tr><th>Username</th><th>Keys</th><th>Admin</th><th></th></tr> +<tr><th>{{t .Lang "admin.users_col_username"}}</th><th>{{t .Lang "admin.users_col_keys"}}</th><th>{{t .Lang "nav.admin"}}</th><th></th></tr> {{range .Users}} <tr> <td>{{.Username}}</td> @@ -22,49 +23,52 @@ var adminUsersTpl = template.Must(template.New("admin-users").Parse(` <form class="inline" method="post" action="/admin/users/set-admin"> <input type="hidden" name="username" value="{{.Username}}"> <input type="hidden" name="is_admin" value="{{if .IsAdmin}}0{{else}}1{{end}}"> - <button type="submit">{{if .IsAdmin}}Revoke admin{{else}}Make admin{{end}}</button> + <button type="submit">{{if .IsAdmin}}{{t $.Lang "admin.revoke_admin"}}{{else}}{{t $.Lang "admin.make_admin"}}{{end}}</button> </form> </td> <td> - <form class="inline" method="post" action="/admin/users/delete" onsubmit="return confirm('Delete user {{.Username}}?');"> + <form class="inline" method="post" action="/admin/users/delete" data-confirm="{{t $.Lang "admin.confirm_delete_user"}} {{.Username}}?"> <input type="hidden" name="username" value="{{.Username}}"> - <button class="danger" type="submit">Delete</button> + <button class="danger" type="submit">{{t $.Lang "settings.remove"}}</button> </form> </td> </tr> {{else}} -<tr><td colspan="4" class="muted">No users yet.</td></tr> +<tr><td colspan="4" class="muted">{{t .Lang "admin.users_empty"}}</td></tr> {{end}} </table> <form class="card" method="post" action="/admin/users"> - <strong>Add user</strong> - <label>Username</label> + <strong>{{t .Lang "admin.add_user"}}</strong> + <label>{{t .Lang "admin.users_col_username"}}</label> <input name="username" required placeholder="alice"> - <label>Public key (authorized_keys format)</label> + <label>{{t .Lang "settings.pubkey_label"}}</label> <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment"> - <label>Initial password</label> + <label>{{t .Lang "admin.initial_password"}}</label> <input name="password" type="password" required minlength="8"> - <label><input type="checkbox" name="is_admin" value="1" style="width:auto; display:inline-block;"> Grant admin</label> - <button type="submit">Create</button> + <label><input type="checkbox" name="is_admin" value="1" style="width:auto; display:inline-block;"> {{t .Lang "admin.grant_admin"}}</label> + <button type="submit">{{t .Lang "dashboard.create"}}</button> </form> -`)) +`) func (s *server) handleAdminUsersList(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) users, err := s.ops.ListUsers() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } var buf bytes.Buffer _ = adminUsersTpl.Execute(&buf, struct { Users []store.User + Lang string Flash template.HTML - }{users, flash(r)}) - s.render(w, r, "Admin — Users", "admin", template.HTML(buf.String())) + }{users, string(lang), flash(r)}) + s.render(w, r, i18n.T(lang, "admin.users_title"), "admin", template.HTML(buf.String())) } func (s *server) handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) username := r.FormValue("username") pubkey := r.FormValue("pubkey") password := r.FormValue("password") @@ -83,24 +87,26 @@ func (s *server) handleAdminUsersCreate(w http.ResponseWriter, r *http.Request) return } } - redirectWithMsg(w, r, "/admin/users", "created user "+username, false) + redirectWithMsg(w, r, "/admin/users", i18n.T(lang, "admin.msg_user_created", username), false) } func (s *server) handleAdminUsersDelete(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) username := r.FormValue("username") if err := s.ops.DeleteUser(username); err != nil { redirectWithMsg(w, r, "/admin/users", err.Error(), true) return } - redirectWithMsg(w, r, "/admin/users", "deleted user "+username, false) + redirectWithMsg(w, r, "/admin/users", i18n.T(lang, "admin.msg_user_deleted", username), false) } func (s *server) handleAdminUsersSetAdmin(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) username := r.FormValue("username") isAdmin := r.FormValue("is_admin") == "1" if err := s.ops.SetUserAdmin(username, isAdmin); err != nil { redirectWithMsg(w, r, "/admin/users", err.Error(), true) return } - redirectWithMsg(w, r, "/admin/users", "updated "+username, false) + redirectWithMsg(w, r, "/admin/users", i18n.T(lang, "admin.msg_user_updated", username), false) }
cmd/gitfed-web/handlers_auth.go
diff --git a/cmd/gitfed-web/handlers_auth.go b/cmd/gitfed-web/handlers_auth.go index d91d7c5..87d53ef 100644 --- a/cmd/gitfed-web/handlers_auth.go +++ b/cmd/gitfed-web/handlers_auth.go @@ -3,57 +3,80 @@ package main import ( "bytes" "html/template" + "log" "net/http" "strings" + + "gitfed/internal/i18n" ) -var loginTpl = template.Must(template.New("login").Parse(` +var loginTpl = newTpl("login", ` {{.Flash}} <form class="card" method="post" action="/login"> - <strong>Log in</strong> + <strong>{{t .Lang "auth.login"}}</strong> <input type="hidden" name="next" value="{{.Next}}"> - <label>Username</label> + <label>{{t .Lang "auth.username"}}</label> <input name="username" required autofocus> - <label>Password</label> + <label>{{t .Lang "auth.password"}}</label> <input name="password" type="password" required> - <button type="submit">Log in</button> + <button type="submit">{{t .Lang "auth.login"}}</button> </form> -<p class="muted">Accounts are created by an instance admin — there's no self-registration. This only logs you into the web UI; git push/pull still goes over SSH with your key.</p> -`)) +<p class="muted">{{t .Lang "auth.note"}}</p> +`) func (s *server) handleLoginForm(w http.ResponseWriter, r *http.Request) { if _, ok := s.currentSession(r); ok { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) return } + lang := s.lang(r) next := sanitizeNext(r.URL.Query().Get("next")) var buf bytes.Buffer _ = loginTpl.Execute(&buf, struct { - Next string - Flash template.HTML - }{next, flash(r)}) - s.render(w, r, "Log in", "login", template.HTML(buf.String())) + Next, Lang string + Flash template.HTML + }{next, string(lang), flash(r)}) + s.render(w, r, i18n.T(lang, "auth.login"), "login", template.HTML(buf.String())) } func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") + lang := s.lang(r) next := sanitizeNext(r.FormValue("next")) + loginURL := "/login?next=" + template.URLQueryEscaper(next) + + // Refuse before touching bcrypt once either the source IP or the target + // account has accumulated too many recent failures. + ip := clientIP(r) + if !s.loginByIP.allowed(ip) || !s.loginByUser.allowed(username) { + redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.rate_limited"), true) + return + } isAdmin, ok, err := s.ops.VerifyPassword(username, password) if err != nil { - redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), err.Error(), true) + log.Printf("gitfed-web: verify password for %q: %v", username, err) + redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.error"), true) return } if !ok { - redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), "invalid username or password", true) + s.loginByIP.recordFailure(ip) + s.loginByUser.recordFailure(username) + redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.invalid_login"), true) return } + // Proven identity — clear the counters so a later typo isn't penalised + // against an earlier attacker's tally. + s.loginByIP.reset(ip) + s.loginByUser.reset(username) + principal := username + "@" + s.domain token, err := s.ops.CreateSession(principal, username, isAdmin) if err != nil { - redirectWithMsg(w, r, "/login?next="+template.URLQueryEscaper(next), err.Error(), true) + log.Printf("gitfed-web: create session for %q: %v", username, err) + redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.error"), true) return } setSessionCookie(w, token)
cmd/gitfed-web/handlers_changelog.go
diff --git a/cmd/gitfed-web/handlers_changelog.go b/cmd/gitfed-web/handlers_changelog.go index 81bb92d..56e6ffe 100644 --- a/cmd/gitfed-web/handlers_changelog.go +++ b/cmd/gitfed-web/handlers_changelog.go @@ -5,14 +5,15 @@ import ( "net/http" "gitfed" + "gitfed/internal/i18n" ) func (s *server) handleChangelog(w http.ResponseWriter, r *http.Request) { rendered, err := renderMarkdown(gitfed.Changelog) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } body := `<div class="markdown-body">` + string(rendered) + `</div>` - s.render(w, r, "Changelog", "", template.HTML(body)) + s.render(w, r, i18n.T(s.lang(r), "changelog.title"), "", template.HTML(body)) }
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go index d3642eb..f668def 100644 --- a/cmd/gitfed-web/handlers_dashboard.go +++ b/cmd/gitfed-web/handlers_dashboard.go @@ -4,26 +4,28 @@ import ( "bytes" "html/template" "net/http" + "strings" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var dashboardTpl = template.Must(template.New("dashboard").Parse(` +var dashboardTpl = newTpl("dashboard", ` {{.Flash}} <div class="gf-stat-row"> - <div class="gf-stat"><div class="n">{{len .Repos}}</div><div class="l">Repos</div></div> - <div class="gf-stat"><div class="n">{{.PublicCount}}</div><div class="l">Public</div></div> - <div class="gf-stat"><div class="n">{{.SharedCount}}</div><div class="l">Shared with you</div></div> + <div class="gf-stat"><div class="n">{{len .Repos}}</div><div class="l">{{t .Lang "dashboard.stat_repos"}}</div></div> + <div class="gf-stat"><div class="n">{{.PublicCount}}</div><div class="l">{{t .Lang "dashboard.stat_public"}}</div></div> + <div class="gf-stat"><div class="n">{{.SharedCount}}</div><div class="l">{{t .Lang "dashboard.stat_shared"}}</div></div> </div> <div class="gf-page-head"> - <h1>Your repos</h1> + <h1>{{t .Lang "dashboard.title"}}</h1> <details class="gf-new-repo"> - <summary class="gf-btn primary" style="margin:0;">+ New repo</summary> + <summary class="gf-btn primary" style="margin:0;">+ {{t .Lang "dashboard.new_repo"}}</summary> <form class="card" method="post" action="/repos" style="margin-top:0.75rem;"> - <label>Name</label> + <label>{{t .Lang "dashboard.repo_name"}}</label> <input name="name" required placeholder="{{.Username}}/my-project"> - <button type="submit">Create</button> + <button type="submit">{{t .Lang "dashboard.create"}}</button> </form> </details> </div> @@ -31,18 +33,18 @@ var dashboardTpl = template.Must(template.New("dashboard").Parse(` <div class="gf-card gf-repo-list"> {{range .Repos}} <div class="gf-repo-row"> - <div class="gf-repo-icon">📁</div> + <div class="gf-repo-icon">{{icon "folder"}}</div> <div class="gf-repo-main"> <div class="name"><a href="/r/{{.Name}}">{{.Name}}</a></div> - <div class="meta">{{.Role}}{{if .Topics}} · {{end}}{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</div> + <div class="meta">{{roleLabel $.Lang .Role}}{{if .Topics}} · {{end}}{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</div> </div> - {{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}} + {{if .Public}}<span class="badge trusted">{{t $.Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t $.Lang "common.private"}}</span>{{end}} </div> {{else}} - <div class="gf-repo-row"><span class="muted">No repos yet — create one above.</span></div> + <div class="gf-repo-row"><span class="muted">{{t .Lang "dashboard.empty"}}</span></div> {{end}} </div> -`)) +`) type dashboardRepo struct { store.Repo @@ -51,10 +53,11 @@ type dashboardRepo struct { func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) + lang := s.lang(r) all, err := s.ops.ListRepos() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } var mine []dashboardRepo @@ -76,19 +79,57 @@ func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) { var buf bytes.Buffer _ = dashboardTpl.Execute(&buf, struct { Repos []dashboardRepo - Username string + Username, Lang string PublicCount, SharedCount int Flash template.HTML - }{mine, sess.Username, publicCount, sharedCount, flash(r)}) - s.render(w, r, "Dashboard", "dashboard", template.HTML(buf.String())) + }{mine, sess.Username, string(lang), publicCount, sharedCount, flash(r)}) + s.render(w, r, i18n.T(lang, "dashboard.title"), "dashboard", template.HTML(buf.String())) } +// maxReposPerUser caps how many repos one account may own, so a single +// logged-in user can't exhaust disk by mass-creating repos. +const maxReposPerUser = 100 + func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) - name := r.FormValue("name") + lang := s.lang(r) + name := strings.Trim(strings.TrimSpace(r.FormValue("name")), "/") + + // Keep self-service repos inside the creator's own namespace: a bare name + // is prefixed with the username; a "namespace/name" form must use the + // user's own namespace. This blocks squatting another user's prefix. + if name == "" { + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_name_required"), true) + return + } + if strings.Contains(name, "/") { + if !strings.HasPrefix(name, sess.Username+"/") { + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_bad_namespace"), true) + return + } + } else { + name = sess.Username + "/" + name + } + + if repos, err := s.ops.ListRepos(); err != nil { + s.serverError(w, r, err) + return + } else { + owned := 0 + for _, repo := range repos { + if repo.Owner == sess.Principal { + owned++ + } + } + if owned >= maxReposPerUser { + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_quota"), true) + return + } + } + if err := s.ops.CreateRepo(name, sess.Username); err != nil { redirectWithMsg(w, r, "/dashboard", err.Error(), true) return } - redirectWithMsg(w, r, "/repo-settings/"+name, "created "+name, false) + redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_created", name), false) }
cmd/gitfed-web/handlers_home.go
diff --git a/cmd/gitfed-web/handlers_home.go b/cmd/gitfed-web/handlers_home.go index cc8e071..fd8c2f0 100644 --- a/cmd/gitfed-web/handlers_home.go +++ b/cmd/gitfed-web/handlers_home.go @@ -5,33 +5,35 @@ import ( "html/template" "net/http" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var homeTpl = template.Must(template.New("home").Parse(` +var exploreTpl = newTpl("explore", ` {{.Flash}} -<h1>Explore public repositories</h1> -{{if .Topic}}<p class="muted">Filtered by topic <span class="badge plain">{{.Topic}}</span> — <a href="/">clear</a></p>{{end}} +<h1>{{t .Lang "explore.title"}}</h1> +{{if .Topic}}<p class="muted">{{t .Lang "explore.filtered_by"}} <span class="badge plain">{{.Topic}}</span> — <a href="/explore">{{t .Lang "explore.clear"}}</a></p>{{end}} <table> -<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr> +<tr><th>{{t .Lang "explore.col_repo"}}</th><th>{{t .Lang "explore.col_owner"}}</th><th>{{t .Lang "explore.col_topics"}}</th></tr> {{range .Repos}} <tr> <td><a href="/r/{{.Name}}">{{.Name}}</a></td> <td class="muted">{{.Owner}}</td> - <td>{{range .Topics}}<a class="badge plain" href="/?topic={{.}}">{{.}}</a>{{end}}</td> + <td>{{range .Topics}}<a class="badge plain" href="/explore?topic={{.}}">{{.}}</a>{{end}}</td> </tr> {{else}} -<tr><td colspan="3" class="muted">No public repositories yet.</td></tr> +<tr><td colspan="3" class="muted">{{t .Lang "explore.empty"}}</td></tr> {{end}} </table> -`)) +`) -func (s *server) handleHome(w http.ResponseWriter, r *http.Request) { +func (s *server) handleExplore(w http.ResponseWriter, r *http.Request) { topic := r.URL.Query().Get("topic") + lang := s.lang(r) repos, err := s.ops.ListRepos() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } var visible []store.Repo @@ -46,12 +48,12 @@ func (s *server) handleHome(w http.ResponseWriter, r *http.Request) { } var buf bytes.Buffer - _ = homeTpl.Execute(&buf, struct { - Repos []store.Repo - Topic string - Flash template.HTML - }{visible, topic, flash(r)}) - s.render(w, r, "Explore", "home", template.HTML(buf.String())) + _ = exploreTpl.Execute(&buf, struct { + Repos []store.Repo + Topic, Lang string + Flash template.HTML + }{visible, topic, string(lang), flash(r)}) + s.render(w, r, i18n.T(lang, "explore.title"), "explore", template.HTML(buf.String())) } func containsTopic(topics []string, topic string) bool {
cmd/gitfed-web/handlers_landing.go
diff --git a/cmd/gitfed-web/handlers_landing.go b/cmd/gitfed-web/handlers_landing.go new file mode 100644 index 0000000..a4a1a85 --- /dev/null +++ b/cmd/gitfed-web/handlers_landing.go @@ -0,0 +1,95 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + + "gitfed/internal/i18n" +) + +// landingTpl is the philosophy/pitch page served at "/" — what gitfed is +// for, not what it does. The public repo browser lives at /explore. +var landingTpl = newTpl("landing", ` +<section class="hero"> + <span class="kicker">{{icon "network"}} {{t .Lang "landing.kicker"}}</span> + <h1>{{t .Lang "landing.h1_1"}} <em>{{t .Lang "landing.h1_2"}}</em></h1> + <p class="lead">{{t .Lang "landing.lead"}}</p> + <div class="hero-actions"> + <a class="btn btn-primary" href="/explore">{{t .Lang "landing.cta_explore"}}</a> + <a class="btn btn-secondary" href="/security">{{t .Lang "landing.cta_security"}}</a> + </div> +</section> + +<div class="pillars"> + <div class="pillar"> + <div class="picon">{{icon "lock"}}</div> + <h3>{{t .Lang "landing.pillar1_h"}}</h3> + <p>{{t .Lang "landing.pillar1_p"}}</p> + </div> + <div class="pillar"> + <div class="picon">{{icon "network"}}</div> + <h3>{{t .Lang "landing.pillar2_h"}}</h3> + <p>{{t .Lang "landing.pillar2_p"}}</p> + </div> + <div class="pillar"> + <div class="picon">{{icon "sliders"}}</div> + <h3>{{t .Lang "landing.pillar3_h"}}</h3> + <p>{{t .Lang "landing.pillar3_p"}}</p> + </div> +</div> + +<div class="split"> + <div> + <h2>{{t .Lang "landing.split_h"}}</h2> + <p>{{t .Lang "landing.split_p"}}</p> + <div class="links"> + <a href="/security">{{t .Lang "landing.split_link_security"}} {{icon "arrow"}}</a> + <a href="/explore">{{t .Lang "landing.split_link_explore"}} {{icon "arrow"}}</a> + </div> + </div> + <div class="fed-diagram"> + <svg viewBox="0 0 340 190" xmlns="http://www.w3.org/2000/svg"> + <rect x="10" y="20" width="130" height="60" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="75" y="45" text-anchor="middle" fill="var(--text)" font-size="11" font-weight="700">{{t .Lang "landing.diagram_you"}}</text> + <text x="75" y="62" text-anchor="middle" fill="var(--text-dim)" font-size="9" font-family="monospace">alice@chez-moi.fr</text> + <rect x="200" y="20" width="130" height="60" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="265" y="45" text-anchor="middle" fill="var(--text)" font-size="11" font-weight="700">{{t .Lang "landing.diagram_friend"}}</text> + <text x="265" y="62" text-anchor="middle" fill="var(--text-dim)" font-size="9" font-family="monospace">bob@ailleurs.net</text> + <path d="M140 50h56" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#lp-arr1)"/> + <path d="M200 60l-56 0" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#lp-arr1)" transform="translate(0,10)"/> + <text x="170" y="45" text-anchor="middle" fill="var(--accent)" font-size="8.5" font-family="monospace">{{t .Lang "landing.diagram_cert"}}</text> + <text x="170" y="80" text-anchor="middle" fill="var(--ok-fg)" font-size="8.5" font-family="monospace">{{t .Lang "landing.diagram_trust"}}</text> + <rect x="105" y="120" width="130" height="50" rx="4" fill="var(--surface)" stroke="var(--border)" stroke-dasharray="3 3"/> + <text x="170" y="140" text-anchor="middle" fill="var(--text-faint)" font-size="9" font-family="monospace">{{t .Lang "landing.diagram_no_account1"}}</text> + <text x="170" y="154" text-anchor="middle" fill="var(--text-faint)" font-size="9" font-family="monospace">{{t .Lang "landing.diagram_no_account2"}}</text> + <defs><marker id="lp-arr1" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--accent)"/></marker></defs> + </svg> + </div> +</div> + +<div class="cta-band"> + <h2>{{t .Lang "landing.cta_band_h"}}</h2> + <p>{{t .Lang "landing.cta_band_p"}}</p> + <div class="hero-actions"> + {{if .LoggedIn}} + <a class="btn btn-primary" href="/dashboard">{{t .Lang "landing.cta_band_dashboard"}}</a> + {{else}} + <a class="btn btn-primary" href="/explore">{{t .Lang "landing.cta_explore"}}</a> + <a class="btn btn-secondary" href="/login">{{t .Lang "nav.login"}}</a> + {{end}} + </div> +</div> +`) + +func (s *server) handleLanding(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) + _, loggedIn := s.currentSession(r) + + var buf bytes.Buffer + _ = landingTpl.Execute(&buf, struct { + Lang string + LoggedIn bool + }{string(lang), loggedIn}) + s.render(w, r, i18n.T(lang, "landing.title"), "home", template.HTML(buf.String())) +}
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 595e0af..0f1ccea 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -8,6 +8,7 @@ import ( "strings" "gitfed/internal/gitexec" + "gitfed/internal/i18n" "gitfed/internal/store" ) @@ -73,24 +74,24 @@ type treeEntryView struct { // repoTpl renders the repo's file tree at the current path, GitLab-style: // files/dirs at the top, README (and, root only, LICENSE) rendered directly // below — no separate "browse files" page. -var repoTpl = template.Must(template.New("repo").Parse(` +var repoTpl = newTpl("repo", ` {{.Flash}} -<div class="gf-crumbs"><a href="/">Explore</a><span class="sep">/</span>{{.Repo.Name}}</div> +<div class="gf-crumbs"><a href="/explore">{{t .Lang "nav.explore"}}</a><span class="sep">/</span>{{.Repo.Name}}</div> <div class="gf-repo-head"> <h1>{{.Repo.Name}}</h1> - {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}} + {{if .Repo.Public}}<span class="badge trusted">{{t .Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t .Lang "common.private"}}</span>{{end}} {{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}} </div> -<p class="muted">owner: {{.Repo.Owner}}</p> +<p class="muted">{{t .Lang "repo.owner"}}: {{.Repo.Owner}}</p> <div class="gf-action-bar"> - {{if .Branch}}<span class="gf-btn">⎇ {{.Branch}}</span>{{end}} + {{if .Branch}}<span class="gf-btn">{{icon "branch"}} {{.Branch}}</span>{{end}} <div class="gf-clone-url"> <code>{{.CloneURL}}</code> - <button type="button" class="linklike" data-copy="{{.CloneURL}}" title="Copy clone URL" aria-label="Copy clone URL">⧉</button> + <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> - {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">Settings</a>{{end}} + {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}} </div> {{if .Crumbs}} @@ -101,48 +102,49 @@ var repoTpl = template.Must(template.New("repo").Parse(` {{if .Empty}} <div class="gf-card" style="padding: 1.5rem;"> - <p class="muted" style="margin: 0 0 0.6rem;">This repository is empty.</p> + <p class="muted" style="margin: 0 0 0.6rem;">{{t .Lang "repo.empty"}}</p> <code>git clone {{.CloneURL}}</code> </div> {{else}} <div class="gf-card"> <table class="gf-file-table"> - {{if .ShowUp}}<tr><td class="icon">📁</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}} + {{if .ShowUp}}<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}} {{range .Entries}} {{if eq .Type "tree"}} - <tr><td class="icon">📁</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">directory</td></tr> + <tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.directory"}}</td></tr> {{else}} - <tr><td class="icon">📄</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">file</td></tr> + <tr><td class="icon">{{icon "file"}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr> {{end}} {{else}} - {{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">Nothing here.</td></tr>{{end}} + {{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">{{t .Lang "repo.nothing_here"}}</td></tr>{{end}} {{end}} </table> </div> {{end}} {{if .Tags}} -<section><h3>Tags</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section> +<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section> {{end}} {{if .ReadmeHTML}} <div class="gf-card"> - <div class="gf-readme-head">📄 README</div> + <div class="gf-readme-head">{{icon "file"}} README</div> <div class="gf-readme-body markdown-body">{{.ReadmeHTML}}</div> </div> {{end}} {{if .LicenseHTML}} <div class="gf-card"> - <div class="gf-readme-head">📄 License ({{.LicenseFile}})</div> + <div class="gf-readme-head">{{icon "file"}} {{t .Lang "repo.license"}} ({{.LicenseFile}})</div> <div class="gf-readme-body markdown-body">{{.LicenseHTML}}</div> </div> {{end}} -`)) +`) func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") path := strings.Trim(r.URL.Query().Get("path"), "/") + lang := s.lang(r) repo, err := s.ops.GetRepo(name) if err != nil || !s.canView(r, repo) { @@ -153,7 +155,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { entries, found, err := s.ops.ListRepoTree(name, path) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } if !found && path != "" { @@ -175,7 +177,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { branch, _, err := s.ops.GetRepoBranch(name) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } @@ -185,34 +187,34 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { if path == "" { readmeContent, readmeFound, err := s.ops.GetRepoReadme(name) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } if readmeFound { readmeHTML, err = renderFileContent("README.md", readmeContent) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } } licenseContent, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } if licenseFound { licenseFile = foundLicenseFile licenseHTML, err = renderFileContent(licenseFile, licenseContent) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } } tags, err = s.ops.ListRepoTags(name) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } } @@ -223,6 +225,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { Domain string CloneURL string Branch string + Lang string Crumbs []crumb Entries []treeEntryView ShowUp bool @@ -235,7 +238,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, + repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", branch, string(lang), breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found, tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r), }) @@ -244,10 +247,10 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { if path != "" { title = path + " — " + name } - s.renderWide(w, r, title, "home", template.HTML(buf.String())) + s.render(w, r, title, "home", template.HTML(buf.String())) } -var blobTpl = template.Must(template.New("blob").Parse(` +var blobTpl = newTpl("blob", ` {{.Flash}} <div class="gf-crumbs"> <a href="/r/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}} @@ -257,11 +260,12 @@ var blobTpl = template.Must(template.New("blob").Parse(` {{.Content}} </div> </div> -`)) +`) func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") path := strings.Trim(r.URL.Query().Get("path"), "/") + lang := s.lang(r) if path == "" { http.NotFound(w, r) return @@ -275,7 +279,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { content, found, err := s.ops.GetRepoFile(name, path) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } if !found { @@ -285,7 +289,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { var rendered template.HTML if strings.Contains(content, "\x00") { - rendered = "<p class=\"muted\">Binary file — not shown.</p>" + rendered = template.HTML(`<p class="muted">` + template.HTMLEscapeString(i18n.T(lang, "repo.binary_file")) + `</p>`) } else { fileName := path if i := strings.LastIndex(fileName, "/"); i >= 0 { @@ -293,7 +297,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { } rendered, err = renderFileContent(fileName, content) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } } @@ -306,7 +310,7 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { Flash template.HTML }{name, breadcrumbs(path), rendered, flash(r)}) - s.renderWide(w, r, path+" — "+name, "home", template.HTML(buf.String())) + s.render(w, r, path+" — "+name, "home", template.HTML(buf.String())) } func renderFileContent(filename, content string) (template.HTML, error) { @@ -322,34 +326,34 @@ func renderFileContent(filename, content string) (template.HTML, error) { var plainTpl = template.Must(template.New("plain").Parse(`<pre>{{.}}</pre>`)) -var repoSettingsTpl = template.Must(template.New("repo-settings").Parse(` +var repoSettingsTpl = newTpl("repo-settings", ` {{.Flash}} <p><a href="/r/{{.Repo}}">&larr; {{.Repo}}</a></p> -<h1>{{.Repo}} settings</h1> +<h1>{{.Repo}} {{t .Lang "repo.settings_title"}}</h1> <form class="card" method="post" action="/repo-settings/{{.Repo}}"> - <strong>Visibility &amp; topics</strong> - <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> Public (readable by any authenticated principal)</label> - <label>Topics (comma-separated)</label> + <strong>{{t .Lang "repo.visibility_topics"}}</strong> + <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> {{t .Lang "repo.public_desc"}}</label> + <label>{{t .Lang "repo.topics_label"}}</label> <input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go"> - <button type="submit">Save</button> + <button type="submit">{{t .Lang "repo.save"}}</button> </form> <table> -<tr><th>Principal</th><th>Role</th><th></th></tr> +<tr><th>{{t .Lang "repo.col_principal"}}</th><th>{{t .Lang "repo.col_role"}}</th><th></th></tr> <tr> <td>{{.Owner}}</td> - <td>admin</td> - <td class="muted">owner</td> + <td>{{t .Lang "role.admin"}}</td> + <td class="muted">{{t .Lang "role.owner"}}</td> </tr> {{range .Collaborators}} <tr> <td>{{.Principal}}</td> - <td>{{.Role}}</td> + <td>{{roleLabel $.Lang (print .Role)}}</td> <td> <form class="inline" method="post" action="/repo-revoke/{{$.Repo}}"> <input type="hidden" name="principal" value="{{.Principal}}"> - <button class="danger" type="submit">Revoke</button> + <button class="danger" type="submit">{{t $.Lang "repo.revoke"}}</button> </form> </td> </tr> @@ -357,27 +361,28 @@ var repoSettingsTpl = template.Must(template.New("repo-settings").Parse(` </table> <form class="card" method="post" action="/repo-grant/{{.Repo}}"> - <strong>Grant collaborator</strong> - <label>Principal</label> + <strong>{{t .Lang "repo.grant_collaborator"}}</strong> + <label>{{t .Lang "repo.col_principal"}}</label> <input name="principal" required placeholder="bob@instanceb.example"> - <label>Role</label> + <label>{{t .Lang "repo.col_role"}}</label> <select name="role"> - <option value="read">read</option> - <option value="write">write</option> - <option value="admin">admin</option> + <option value="read">{{t .Lang "role.read"}}</option> + <option value="write">{{t .Lang "role.write"}}</option> + <option value="admin">{{t .Lang "role.admin"}}</option> </select> - <button type="submit">Grant</button> + <button type="submit">{{t .Lang "repo.grant"}}</button> </form> -<p class="muted">Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin &rarr; Trust store (whitelist policy).</p> +<p class="muted">{{t .Lang "repo.grant_note"}}</p> -<form class="card" method="post" action="/repo-delete/{{.Repo}}" onsubmit="return confirm('Delete {{.Repo}}? This does not delete the bare repo on disk.');"> - <strong>Danger zone</strong> - <button class="danger" type="submit">Delete repo record</button> +<form class="card" method="post" action="/repo-delete/{{.Repo}}" data-confirm="{{t .Lang "repo.confirm_delete"}} {{.Repo}}?"> + <strong>{{t .Lang "repo.danger_zone"}}</strong> + <button class="danger" type="submit">{{t .Lang "repo.delete_record"}}</button> </form> -`)) +`) func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") + lang := s.lang(r) if _, ok := s.canAdminister(r, name); !ok { http.NotFound(w, r) return @@ -389,7 +394,7 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) } acl, err := s.ops.GetACL(name) if err != nil && err != store.ErrNotFound { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } @@ -397,16 +402,18 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) _ = repoSettingsTpl.Execute(&buf, struct { Repo string Owner string + Lang string Public bool TopicsCSV string Collaborators []store.Collaborator Flash template.HTML - }{name, repo.Owner, repo.Public, strings.Join(repo.Topics, ", "), acl.Collaborators, flash(r)}) - s.render(w, r, name+" settings", "home", template.HTML(buf.String())) + }{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), acl.Collaborators, flash(r)}) + s.render(w, r, name+" "+i18n.T(lang, "repo.settings_title"), "home", template.HTML(buf.String())) } func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") + lang := s.lang(r) if _, ok := s.canAdminister(r, name); !ok { http.NotFound(w, r) return @@ -428,11 +435,12 @@ func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) redirectWithMsg(w, r, back, err.Error(), true) return } - redirectWithMsg(w, r, back, "saved settings", false) + redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_saved"), false) } func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") + lang := s.lang(r) if _, ok := s.canAdminister(r, name); !ok { http.NotFound(w, r) return @@ -444,11 +452,12 @@ func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) { redirectWithMsg(w, r, back, err.Error(), true) return } - redirectWithMsg(w, r, back, "granted "+principal+" "+string(role), false) + redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role))), false) } func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") + lang := s.lang(r) if _, ok := s.canAdminister(r, name); !ok { http.NotFound(w, r) return @@ -459,11 +468,12 @@ func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) { redirectWithMsg(w, r, back, err.Error(), true) return } - redirectWithMsg(w, r, back, "revoked "+principal, false) + redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_revoked", principal), false) } func (s *server) handleRepoDelete(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") + lang := s.lang(r) if _, ok := s.canAdminister(r, name); !ok { http.NotFound(w, r) return @@ -472,5 +482,5 @@ func (s *server) handleRepoDelete(w http.ResponseWriter, r *http.Request) { redirectWithMsg(w, r, "/repo-settings/"+url.PathEscape(name), err.Error(), true) return } - redirectWithMsg(w, r, "/dashboard", "deleted "+name, false) + redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "repo.msg_deleted", name), false) }
cmd/gitfed-web/handlers_search.go
diff --git a/cmd/gitfed-web/handlers_search.go b/cmd/gitfed-web/handlers_search.go index 0e54766..d02c554 100644 --- a/cmd/gitfed-web/handlers_search.go +++ b/cmd/gitfed-web/handlers_search.go @@ -6,16 +6,17 @@ import ( "net/http" "strings" + "gitfed/internal/i18n" "gitfed/internal/store" ) -var searchTpl = template.Must(template.New("search").Parse(` -<h1>Search{{if .Query}}: "{{.Query}}"{{end}}</h1> +var searchTpl = newTpl("search", ` +<h1>{{t .Lang "search.title"}}{{if .Query}}: "{{.Query}}"{{end}}</h1> <form action="/search" method="get" style="max-width:420px;"> - <input type="search" name="q" value="{{.Query}}" placeholder="Search repos…" autofocus> + <input type="search" name="q" value="{{.Query}}" placeholder="{{t .Lang "nav.search_placeholder"}}" autofocus> </form> <table> -<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr> +<tr><th>{{t .Lang "explore.col_repo"}}</th><th>{{t .Lang "explore.col_owner"}}</th><th>{{t .Lang "explore.col_topics"}}</th></tr> {{range .Results}} <tr> <td><a href="/r/{{.Name}}">{{.Name}}</a></td> @@ -23,10 +24,10 @@ var searchTpl = template.Must(template.New("search").Parse(` <td>{{range .Topics}}<span class="badge plain">{{.}}</span>{{end}}</td> </tr> {{else}} -<tr><td colspan="3" class="muted">{{if .Query}}No matches.{{else}}Type something to search.{{end}}</td></tr> +<tr><td colspan="3" class="muted">{{if .Query}}{{t .Lang "search.no_matches"}}{{else}}{{t .Lang "search.prompt"}}{{end}}</td></tr> {{end}} </table> -`)) +`) // searchVisible reports whether repo should be considered for search // results for the given (possibly anonymous) session: same visibility rule @@ -38,12 +39,13 @@ func (s *server) searchVisible(r *http.Request, repo store.Repo) bool { func (s *server) handleSearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) + lang := s.lang(r) var results []store.Repo if q != "" { all, err := s.ops.ListRepos() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } needle := strings.ToLower(q) @@ -59,10 +61,10 @@ func (s *server) handleSearch(w http.ResponseWriter, r *http.Request) { var buf bytes.Buffer _ = searchTpl.Execute(&buf, struct { - Query string - Results []store.Repo - }{q, results}) - s.render(w, r, "Search", "", template.HTML(buf.String())) + Query, Lang string + Results []store.Repo + }{q, string(lang), results}) + s.render(w, r, i18n.T(lang, "search.title"), "", template.HTML(buf.String())) } func matchesSearch(repo store.Repo, lowerNeedle string) bool {
cmd/gitfed-web/handlers_security.go
diff --git a/cmd/gitfed-web/handlers_security.go b/cmd/gitfed-web/handlers_security.go new file mode 100644 index 0000000..4446624 --- /dev/null +++ b/cmd/gitfed-web/handlers_security.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + + "gitfed/internal/i18n" +) + +var securityTpl = newTpl("security", ` +<div class="sec-head"> + <span class="kicker">{{icon "lock"}} {{t .Lang "security.kicker"}}</span> + <h1>{{t .Lang "security.h1"}}</h1> + <p>{{t .Lang "security.lead"}}</p> +</div> + +<section class="sec-section"> + <div class="tag">{{t .Lang "security.s1_tag"}}</div> + <h2>{{icon "key"}} {{t .Lang "security.s1_h"}}</h2> + <p>{{t .Lang "security.s1_p"}}</p> + <div class="sec-diagram"> + <svg viewBox="0 0 460 110" xmlns="http://www.w3.org/2000/svg"> + <rect x="10" y="30" width="110" height="50" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="65" y="59" text-anchor="middle" fill="var(--text)" font-size="11">{{t .Lang "security.s1_diagram_client"}}</text> + <path d="M120 55h70" stroke="var(--ok-fg)" stroke-width="1.6" marker-end="url(#s-arr-ok)"/> + <text x="155" y="45" text-anchor="middle" fill="var(--ok-fg)" font-size="9" font-family="monospace">SSH :2222</text> + <rect x="190" y="10" width="150" height="90" rx="6" fill="var(--surface)" stroke="var(--border)"/> + <text x="265" y="35" text-anchor="middle" fill="var(--text)" font-size="11" font-weight="700">gitfed-server</text> + <rect x="205" y="48" width="120" height="24" rx="4" fill="var(--ok-bg)" stroke="var(--ok-fg)"/> + <text x="265" y="64" text-anchor="middle" fill="var(--ok-fg)" font-size="9" font-family="monospace">{{t .Lang "security.s1_diagram_open"}}</text> + <rect x="205" y="76" width="120" height="20" rx="4" fill="var(--danger-bg)" stroke="var(--danger-fg)" stroke-dasharray="3 3"/> + <text x="265" y="90" text-anchor="middle" fill="var(--danger-fg)" font-size="8.5" font-family="monospace">{{t .Lang "security.s1_diagram_http"}}</text> + <rect x="360" y="30" width="90" height="50" rx="4" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="405" y="53" text-anchor="middle" fill="var(--text)" font-size="10">{{t .Lang "security.s1_diagram_repos"}}</text> + <text x="405" y="66" text-anchor="middle" fill="var(--text-dim)" font-size="9">bare git</text> + <path d="M340 55h20" stroke="var(--border-strong)" stroke-width="1.6" marker-end="url(#s-arr-ok)"/> + <defs><marker id="s-arr-ok" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--ok-fg)"/></marker></defs> + </svg> + <div class="cap">{{t .Lang "security.s1_caption"}}</div> + </div> +</section> + +<section class="sec-section"> + <div class="tag">{{t .Lang "security.s2_tag"}}</div> + <h2>{{icon "shield"}} {{t .Lang "security.s2_h"}}</h2> + <p>{{t .Lang "security.s2_p"}}</p> + <div class="sec-diagram"> + <svg viewBox="0 0 520 190" xmlns="http://www.w3.org/2000/svg"> + <circle cx="60" cy="95" r="34" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="60" y="92" text-anchor="middle" fill="var(--text)" font-size="10" font-weight="700">alice</text> + <text x="60" y="104" text-anchor="middle" fill="var(--text-dim)" font-size="8">{{t .Lang "security.s2_diagram_haskey"}}</text> + <path d="M96 95h48" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#s-arr-a)"/> + <text x="120" y="86" text-anchor="middle" fill="var(--accent)" font-size="8" font-family="monospace">{{t .Lang "security.s2_diagram_step1"}}</text> + <rect x="150" y="55" width="120" height="80" rx="6" fill="var(--surface)" stroke="var(--border)"/> + <text x="210" y="78" text-anchor="middle" fill="var(--text)" font-size="10" font-weight="700">{{t .Lang "security.s2_diagram_alice_instance"}}</text> + <text x="210" y="94" text-anchor="middle" fill="var(--text-dim)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_local_ca"}}</text> + <rect x="170" y="102" width="80" height="20" rx="10" fill="rgba(108,157,245,0.12)" stroke="var(--accent)"/> + <text x="210" y="116" text-anchor="middle" fill="var(--accent)" font-size="8" font-family="monospace">{{t .Lang "security.s2_diagram_signs"}}</text> + <path d="M270 95h48" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#s-arr-a)"/> + <text x="294" y="86" text-anchor="middle" fill="var(--accent)" font-size="8" font-family="monospace">{{t .Lang "security.s2_diagram_step2"}}</text> + <rect x="318" y="55" width="120" height="80" rx="6" fill="var(--surface)" stroke="var(--border)"/> + <text x="378" y="78" text-anchor="middle" fill="var(--text)" font-size="10" font-weight="700">{{t .Lang "security.s2_diagram_bob_instance"}}</text> + <text x="378" y="94" text-anchor="middle" fill="var(--text-dim)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_trust1"}}</text> + <text x="378" y="106" text-anchor="middle" fill="var(--text-dim)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_trust2"}}</text> + <path d="M438 95h30" stroke="var(--ok-fg)" stroke-width="1.5" marker-end="url(#s-arr-ok2)"/> + <circle cx="488" cy="95" r="20" fill="var(--ok-bg)" stroke="var(--ok-fg)"/> + <text x="488" y="99" text-anchor="middle" fill="var(--ok-fg)" font-size="9" font-weight="700">{{t .Lang "security.s2_diagram_access"}}</text> + <text x="210" y="160" text-anchor="middle" fill="var(--text-faint)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_local1"}}</text> + <text x="210" y="172" text-anchor="middle" fill="var(--text-faint)" font-size="8.5" font-family="monospace">{{t .Lang "security.s2_diagram_local2"}}</text> + <defs> + <marker id="s-arr-a" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--accent)"/></marker> + <marker id="s-arr-ok2" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--ok-fg)"/></marker> + </defs> + </svg> + <div class="cap">{{t .Lang "security.s2_caption"}}</div> + </div> +</section> + +<section class="sec-section"> + <div class="tag">{{t .Lang "security.s3_tag"}}</div> + <h2>{{icon "network"}} {{t .Lang "security.s3_h"}}</h2> + <p>{{t .Lang "security.s3_p"}}</p> + <div class="sec-diagram"> + <svg viewBox="0 0 560 90" xmlns="http://www.w3.org/2000/svg"> + <rect x="0" y="15" width="118" height="46" rx="6" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="59" y="34" text-anchor="middle" fill="var(--text)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_unknown"}}</text> + <text x="59" y="48" text-anchor="middle" fill="var(--text-dim)" font-size="8">{{t .Lang "security.s3_diagram_unknown_sub"}}</text> + <path d="M118 38h34" stroke="var(--text-faint)" stroke-width="1.5" marker-end="url(#s-arr-g)"/> + <rect x="152" y="15" width="118" height="46" rx="6" fill="var(--surface-2)" stroke="var(--border-strong)"/> + <text x="211" y="34" text-anchor="middle" fill="var(--text)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_discovery"}}</text> + <text x="211" y="48" text-anchor="middle" fill="var(--text-dim)" font-size="8">{{t .Lang "security.s3_diagram_discovery_sub"}}</text> + <path d="M270 38h34" stroke="var(--pending-fg)" stroke-width="1.5" marker-end="url(#s-arr-y)"/> + <rect x="304" y="15" width="118" height="46" rx="6" fill="var(--pending-bg)" stroke="var(--pending-fg)"/> + <text x="363" y="34" text-anchor="middle" fill="var(--pending-fg)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_pending"}}</text> + <text x="363" y="48" text-anchor="middle" fill="var(--pending-fg)" font-size="8">{{t .Lang "security.s3_diagram_pending_sub"}}</text> + <path d="M422 38h34" stroke="var(--ok-fg)" stroke-width="1.5" marker-end="url(#s-arr-g2)"/> + <rect x="456" y="15" width="104" height="46" rx="6" fill="var(--ok-bg)" stroke="var(--ok-fg)"/> + <text x="508" y="34" text-anchor="middle" fill="var(--ok-fg)" font-size="9.5" font-weight="700">{{t .Lang "security.s3_diagram_approved"}}</text> + <text x="508" y="48" text-anchor="middle" fill="var(--ok-fg)" font-size="8">{{t .Lang "security.s3_diagram_approved_sub"}}</text> + <defs> + <marker id="s-arr-g" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--text-faint)"/></marker> + <marker id="s-arr-y" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--pending-fg)"/></marker> + <marker id="s-arr-g2" markerWidth="7" markerHeight="7" refX="5" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="var(--ok-fg)"/></marker> + </defs> + </svg> + </div> +</section> + +<section class="sec-section"> + <div class="tag">{{t .Lang "security.s4_tag"}}</div> + <h2>{{icon "sliders"}} {{t .Lang "security.s4_h"}}</h2> + <p>{{t .Lang "security.s4_p"}}</p> + <div class="sec-facts"> + <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact1_h"}}</strong> — {{t .Lang "security.s4_fact1_p"}}</span></div> + <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact2_h"}}</strong> — {{t .Lang "security.s4_fact2_p"}}</span></div> + <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact3_h"}}</strong> — {{t .Lang "security.s4_fact3_p"}}</span></div> + <div class="sec-fact">{{icon "check"}}<span><strong>{{t .Lang "security.s4_fact4_h"}}</strong> — {{t .Lang "security.s4_fact4_p"}}</span></div> + </div> +</section> +`) + +func (s *server) handleSecurity(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) + var buf bytes.Buffer + _ = securityTpl.Execute(&buf, struct{ Lang string }{string(lang)}) + s.render(w, r, i18n.T(lang, "security.title"), "security", template.HTML(buf.String())) +}
cmd/gitfed-web/handlers_settings.go
diff --git a/cmd/gitfed-web/handlers_settings.go b/cmd/gitfed-web/handlers_settings.go index 1495dd2..78f65eb 100644 --- a/cmd/gitfed-web/handlers_settings.go +++ b/cmd/gitfed-web/handlers_settings.go @@ -4,21 +4,23 @@ import ( "bytes" "html/template" "net/http" + + "gitfed/internal/i18n" ) -var settingsTpl = template.Must(template.New("settings").Parse(` +var settingsTpl = newTpl("settings", ` {{.Flash}} -<div class="gf-page-head"><h1>Settings</h1></div> +<div class="gf-page-head"><h1>{{t .Lang "settings.title"}}</h1></div> <div class="gf-tabs"> - <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">Profile</button> - <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">SSH keys</button> - <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">Password</button> + <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">{{t .Lang "settings.tab_profile"}}</button> + <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">{{t .Lang "settings.tab_keys"}}</button> + <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">{{t .Lang "settings.tab_password"}}</button> </div> <div id="tab-profile" class="gf-tabpane {{if eq .ActiveTab "profile"}}active{{end}}"> - <p class="muted">Logged in as <strong>{{.Username}}</strong>{{if .IsAdmin}} — admin{{end}}.</p> - <p class="muted">This password only signs you into the web UI. Git push/pull always goes over SSH with a key, independently of it.</p> + <p class="muted">{{t .Lang "settings.logged_in_as"}} <strong>{{.Username}}</strong>{{if .IsAdmin}} — {{t .Lang "role.admin"}}{{end}}.</p> + <p class="muted">{{t .Lang "settings.profile_note"}}</p> </div> <div id="tab-keys" class="gf-tabpane {{if eq .ActiveTab "keys"}}active{{end}}"> @@ -28,33 +30,33 @@ var settingsTpl = template.Must(template.New("settings").Parse(` <code>{{.Short}}</code> <form class="inline" method="post" action="/settings/keys/remove"> <input type="hidden" name="key" value="{{.Full}}"> - <button class="danger" type="submit">Remove</button> + <button class="danger" type="submit">{{t $.Lang "settings.remove"}}</button> </form> </div> {{else}} - <div class="gf-key-row"><span class="muted">No keys yet.</span></div> + <div class="gf-key-row"><span class="muted">{{t .Lang "settings.no_keys"}}</span></div> {{end}} </div> <form class="card" method="post" action="/settings/keys/add"> - <strong>Add SSH key</strong> - <label>Public key (authorized_keys format)</label> + <strong>{{t .Lang "settings.add_key"}}</strong> + <label>{{t .Lang "settings.pubkey_label"}}</label> <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment"> - <button type="submit">Add</button> + <button type="submit">{{t .Lang "settings.add"}}</button> </form> </div> <div id="tab-password" class="gf-tabpane {{if eq .ActiveTab "password"}}active{{end}}"> <form class="card" method="post" action="/settings/password"> - <strong>Change password</strong> - <label>Current password</label> + <strong>{{t .Lang "settings.change_password"}}</strong> + <label>{{t .Lang "settings.current_password"}}</label> <input name="old_password" type="password" required> - <label>New password</label> + <label>{{t .Lang "settings.new_password"}}</label> <input name="new_password" type="password" required minlength="8"> - <button type="submit">Change password</button> + <button type="submit">{{t .Lang "settings.change_password"}}</button> </form> </div> -`)) +`) type keyView struct{ Short, Full string } @@ -71,9 +73,10 @@ func settingsTab(r *http.Request) string { func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) + lang := s.lang(r) user, err := s.ops.GetUser(sess.Username) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + s.serverError(w, r, err) return } @@ -88,34 +91,37 @@ func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) { var buf bytes.Buffer _ = settingsTpl.Execute(&buf, struct { - Username, ActiveTab string - IsAdmin bool - Keys []keyView - Flash template.HTML - }{sess.Username, settingsTab(r), sess.IsAdmin, keys, flash(r)}) - s.render(w, r, "Settings", "settings", template.HTML(buf.String())) + Username, ActiveTab, Lang string + IsAdmin bool + Keys []keyView + Flash template.HTML + }{sess.Username, settingsTab(r), string(lang), sess.IsAdmin, keys, flash(r)}) + s.render(w, r, i18n.T(lang, "settings.title"), "settings", template.HTML(buf.String())) } func (s *server) handleAddOwnKey(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) + lang := s.lang(r) if err := s.ops.AddUserKey(sess.Username, r.FormValue("pubkey")); err != nil { redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true) return } - redirectWithMsg(w, r, "/settings?tab=keys", "key added", false) + redirectWithMsg(w, r, "/settings?tab=keys", i18n.T(lang, "settings.msg_key_added"), false) } func (s *server) handleRemoveOwnKey(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) + lang := s.lang(r) if err := s.ops.RemoveUserKey(sess.Username, r.FormValue("key")); err != nil { redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true) return } - redirectWithMsg(w, r, "/settings?tab=keys", "key removed", false) + redirectWithMsg(w, r, "/settings?tab=keys", i18n.T(lang, "settings.msg_key_removed"), false) } func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) + lang := s.lang(r) oldPassword := r.FormValue("old_password") newPassword := r.FormValue("new_password") @@ -125,12 +131,12 @@ func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) { return } if !ok { - redirectWithMsg(w, r, "/settings?tab=password", "current password is incorrect", true) + redirectWithMsg(w, r, "/settings?tab=password", i18n.T(lang, "settings.msg_wrong_password"), true) return } if err := s.ops.SetPassword(sess.Username, newPassword); err != nil { redirectWithMsg(w, r, "/settings?tab=password", err.Error(), true) return } - redirectWithMsg(w, r, "/settings?tab=password", "password changed", false) + redirectWithMsg(w, r, "/settings?tab=password", i18n.T(lang, "settings.msg_password_changed"), false) }
cmd/gitfed-web/lang.go
diff --git a/cmd/gitfed-web/lang.go b/cmd/gitfed-web/lang.go new file mode 100644 index 0000000..3d8fecb --- /dev/null +++ b/cmd/gitfed-web/lang.go @@ -0,0 +1,51 @@ +package main + +import ( + "net/http" + "strings" + "time" + + "gitfed/internal/i18n" +) + +const langCookieName = "gitfed_lang" + +// lang resolves the UI language for a request: an explicit cookie (set via +// the nav switcher) wins, then the browser's Accept-Language header, then +// i18n.Default. +func (s *server) lang(r *http.Request) i18n.Lang { + if c, err := r.Cookie(langCookieName); err == nil { + if l, ok := i18n.ParseLang(c.Value); ok { + return l + } + } + for _, part := range strings.Split(r.Header.Get("Accept-Language"), ",") { + tag := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0])) + tag, _, _ = strings.Cut(tag, "-") + if l, ok := i18n.ParseLang(tag); ok { + return l + } + } + return i18n.Default +} + +// handleSetLang stores the chosen language in a cookie and bounces back to +// wherever the switcher was clicked from. +func (s *server) handleSetLang(w http.ResponseWriter, r *http.Request) { + if _, ok := i18n.ParseLang(r.PathValue("lang")); ok { + http.SetCookie(w, &http.Cookie{ + Name: langCookieName, + Value: r.PathValue("lang"), + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(365 * 24 * time.Hour), + }) + } + next := r.URL.Query().Get("next") + if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { + next = "/" + } + http.Redirect(w, r, next, http.StatusSeeOther) +}
cmd/gitfed-web/main.go
diff --git a/cmd/gitfed-web/main.go b/cmd/gitfed-web/main.go index 299985e..98f8c71 100644 --- a/cmd/gitfed-web/main.go +++ b/cmd/gitfed-web/main.go @@ -16,6 +16,7 @@ import ( "fmt" "net/http" "os" + "time" "gitfed/internal/admin" "gitfed/internal/config" @@ -25,6 +26,9 @@ import ( type server struct { ops admin.Ops domain string + + loginByIP *loginLimiter + loginByUser *loginLimiter } func main() { @@ -46,13 +50,35 @@ func main() { } defer closeFn() - s := &server{ops: ops, domain: cfg.Domain} + s := &server{ + ops: ops, + domain: cfg.Domain, + // Per-account is the tighter bound (an attacker targeting one login); + // per-IP is looser but catches spraying across many usernames from + // one source. Either tripping blocks the attempt. + loginByUser: newLoginLimiter(5, 15*time.Minute), + loginByIP: newLoginLimiter(20, 15*time.Minute), + } mux := http.NewServeMux() s.routes(mux) + // Security middleware wraps the whole mux: same-origin enforcement on + // state-changing requests (CSRF defense-in-depth on top of SameSite + // cookies) and the standard hardening response headers. + handler := securityHeaders(s.sameOriginPOST(mux)) + + srv := &http.Server{ + Addr: *listen, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + fmt.Printf("gitfed-web serving on %s (mode: %s)\n", *listen, mode) - if err := http.ListenAndServe(*listen, mux); err != nil { + if err := srv.ListenAndServe(); err != nil { fmt.Fprintln(os.Stderr, "gitfed-web:", err) os.Exit(1) }
cmd/gitfed-web/ratelimit.go
diff --git a/cmd/gitfed-web/ratelimit.go b/cmd/gitfed-web/ratelimit.go new file mode 100644 index 0000000..0faf3d4 --- /dev/null +++ b/cmd/gitfed-web/ratelimit.go @@ -0,0 +1,102 @@ +package main + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +// loginLimiter is a small in-memory fixed-window rate limiter used to blunt +// brute-force / credential-stuffing against the web login. It counts failed +// attempts per key (client IP or username) and refuses new attempts once +// max failures accumulate within window. It is intentionally process-local: +// the web UI runs as a single replica (see deploy/k8s/deployment.yaml), so +// there's no shared-state requirement, and a restart clearing the counters +// is an acceptable, fail-open-on-restart trade-off. +type loginLimiter struct { + mu sync.Mutex + hits map[string]*hitWindow + max int + window time.Duration +} + +type hitWindow struct { + count int + reset time.Time +} + +func newLoginLimiter(max int, window time.Duration) *loginLimiter { + l := &loginLimiter{hits: make(map[string]*hitWindow), max: max, window: window} + go l.gcLoop() + return l +} + +// allowed reports whether an attempt for key may proceed right now. It does +// not record anything — call recordFailure only on an actual auth failure, +// so a legitimate user who logs in correctly is never counted. +func (l *loginLimiter) allowed(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + w := l.hits[key] + if w == nil || time.Now().After(w.reset) { + return true + } + return w.count < l.max +} + +func (l *loginLimiter) recordFailure(key string) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + if w := l.hits[key]; w != nil && !now.After(w.reset) { + w.count++ + return + } + l.hits[key] = &hitWindow{count: 1, reset: now.Add(l.window)} +} + +// reset clears the counter for key, e.g. after a successful login so the +// window doesn't linger against a user who has proven who they are. +func (l *loginLimiter) reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.hits, key) +} + +func (l *loginLimiter) gcLoop() { + for range time.Tick(l.window) { + l.mu.Lock() + now := time.Now() + for k, w := range l.hits { + if now.After(w.reset) { + delete(l.hits, k) + } + } + l.mu.Unlock() + } +} + +// clientIP extracts the caller's IP for rate-limiting. Behind the ingress +// (Traefik) the real client address arrives in X-Real-IP or as the last hop +// of X-Forwarded-For — the last entry is the one our own trusted proxy +// appended, so it can't be spoofed by the client the way the left-most +// (client-supplied) entry can. Falls back to the transport RemoteAddr when +// no proxy header is present (direct connection in dev). +func clientIP(r *http.Request) string { + if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" { + return xr + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if last := strings.TrimSpace(parts[len(parts)-1]); last != "" { + return last + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 76fba94..9b879a5 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -3,6 +3,7 @@ package main import ( "bytes" "html/template" + "log" "net/http" "strings" "unicode" @@ -10,9 +11,24 @@ import ( "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" + "gitfed/internal/i18n" "gitfed/internal/version" ) +// commonFuncs is shared by every page template so any of them can call +// {{t $.Lang "some.key"}} — see internal/i18n. Bound once at Parse time via +// newTpl rather than per-request, since the language is passed as an +// explicit argument instead of captured in a closure. +var commonFuncs = template.FuncMap{ + "t": func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) }, + "icon": icon, + "roleLabel": func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) }, +} + +func newTpl(name, src string) *template.Template { + return template.Must(template.New(name).Funcs(commonFuncs).Parse(src)) +} + var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM)) // renderMarkdown converts src to HTML. goldmark escapes any raw HTML found @@ -33,7 +49,46 @@ func renderMarkdown(src string) (template.HTML, error) { // favicon below. const brandMark = `<svg class="brand-mark" width="18" height="18" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>` -const shellSrc = `<!doctype html> +// iconSprite is a single SVG <symbol> sheet, injected once right after +// <body>, that every page and every icon="ic-*" <use> in the app draws +// from — the line-icon set that replaces emoji throughout the UI, drawn +// with the same square-cornered vocabulary as brandMark rather than a +// generic icon font. +const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"> +<defs> +<symbol id="ic-folder" viewBox="0 0 24 24"><path d="M3 6.2c0-.66.54-1.2 1.2-1.2h4l1.6 1.8h7c.66 0 1.2.54 1.2 1.2v8.6c0 .66-.54 1.2-1.2 1.2H4.2c-.66 0-1.2-.54-1.2-1.2V6.2Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> +<symbol id="ic-file" viewBox="0 0 24 24"><path d="M6 3.5h7l4 4v12c0 .55-.45 1-1 1H6c-.55 0-1-.45-1-1v-15c0-.55.45-1 1-1Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M13 3.5v4h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> +<symbol id="ic-branch" viewBox="0 0 24 24"><rect x="10.5" y="12" width="3" height="8.5" fill="currentColor"/><rect x="3.3" y="3" width="3" height="8" fill="currentColor" transform="rotate(35 4.8 7)"/><rect x="16.7" y="3" width="3" height="8" fill="currentColor" transform="rotate(-35 18.2 7)"/></symbol> +<symbol id="ic-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="10.5" height="12.5" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 15V4.6c0-.6.48-1.1 1.1-1.1H16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> +<symbol id="ic-check" viewBox="0 0 24 24"><path d="M4.5 12.5l4.6 4.6L19.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol> +<symbol id="ic-settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 3.6v2.3M12 18.1v2.3M20.4 12h-2.3M5.9 12H3.6M17.5 6.5l-1.6 1.6M8.1 15.9l-1.6 1.6M17.5 17.5l-1.6-1.6M8.1 8.1 6.5 6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> +<symbol id="ic-shield" viewBox="0 0 24 24"><path d="M12 3.4 19 6v5.6c0 4.7-3 7.9-7 9-4-1.1-7-4.3-7-9V6l7-2.6Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9 12.2l2 2 4-4.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol> +<symbol id="ic-logout" viewBox="0 0 24 24"><path d="M10.5 4.5H6.2C5.5 4.5 5 5 5 5.7v12.6c0 .66.54 1.2 1.2 1.2h4.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14.5 8.2 18.3 12l-3.8 3.8M18.3 12H9.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol> +<symbol id="ic-lock" viewBox="0 0 24 24"><rect x="5.5" y="10.5" width="13" height="10" rx="1.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 10.5V7.8a4 4 0 0 1 8 0v2.7" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> +<symbol id="ic-key" viewBox="0 0 24 24"><circle cx="8" cy="15" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11.3 12 20 3.3M17 6.3l2 2M14 9.3l2 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> +<symbol id="ic-network" viewBox="0 0 24 24"><circle cx="12" cy="4.6" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5" cy="18" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="19" cy="18" r="2.1" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 6.7v4.3M10.4 12.5 6.3 16M13.6 12.5 17.7 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> +<symbol id="ic-sliders" viewBox="0 0 24 24"><path d="M5 6h9M17.5 6H19M5 12h5.5M13 12H19M5 18h9M17.5 18H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="12" cy="6" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="9.5" cy="12" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="15.5" cy="18" r="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> +<symbol id="ic-arrow" viewBox="0 0 24 24"><path d="M4 12h14.5M13 6.5l6 5.5-6 5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol> +</defs> +</svg>` + +// icon renders a use of one of iconSprite's symbols, sized to sit inline +// with text (buttons, table cells, menu items). +func icon(name string) template.HTML { + return template.HTML(`<svg class="icon" aria-hidden="true"><use href="#ic-` + name + `"/></svg>`) +} + +// roleLabel translates an ACL role (or the synthetic "owner") for display. +func roleLabel(lang i18n.Lang, role string) string { + switch role { + case "owner", "read", "write", "admin": + return i18n.T(lang, "role."+role) + default: + return role + } +} + +const shellHeadSrc = `<!doctype html> <html> <head> <meta charset="utf-8"> @@ -78,6 +133,12 @@ const shellSrc = `<!doctype html> .gf-nav-right a:hover { color: var(--text); } .linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; } .linklike:hover { color: var(--text); } + .icon { width: 15px; height: 15px; flex-shrink: 0; vertical-align: -0.15em; } + + /* --- language switcher --- */ + .gf-lang { display: flex; align-items: center; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 2px; font-size: 0.72rem; font-weight: 700; flex-shrink: 0; } + .gf-lang a { display: block; color: var(--text-faint); text-decoration: none; padding: 0.3rem 0.55rem; border-radius: 999px; letter-spacing: 0.02em; } + .gf-lang a.active { background: var(--accent); color: var(--accent-ink); } /* --- profile dropdown --- */ .gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; } @@ -112,9 +173,65 @@ const shellSrc = `<!doctype html> /* ---------- layout ---------- */ main { padding: 1.5rem 1rem; max-width: 980px; margin: 0 auto; } - main.wide { max-width: none; } section { margin-bottom: 2rem; } + /* ---------- landing page ---------- */ + .hero { padding: 2.6rem 0 2rem; text-align: center; } + .hero .kicker { display: inline-flex; align-items: center; gap: 0.45rem; font-family: var(--mono); font-size: 0.76rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); background: rgba(108,157,245,0.1); border: 1px solid rgba(108,157,245,0.25); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1.4rem; } + .hero h1 { font-size: clamp(1.8rem, 4.4vw, 2.5rem); line-height: 1.14; margin: 0 auto 1rem; max-width: 640px; font-weight: 700; letter-spacing: -0.01em; } + .hero h1 em { color: var(--accent); font-style: normal; } + .hero p.lead { color: var(--text-dim); font-size: 1.02rem; max-width: 540px; margin: 0 auto 1.7rem; } + .hero-actions { display: flex; gap: 0.7rem; justify-content: center; flex-wrap: wrap; } + .btn { display: inline-flex; align-items: center; gap: 0.5rem; font-family: inherit; font-size: 0.9rem; font-weight: 600; padding: 0.65rem 1.15rem; border-radius: 8px; text-decoration: none; cursor: pointer; border: 1px solid transparent; margin-top: 0; } + .btn-primary { background: var(--accent); color: var(--accent-ink); } + .btn-primary:hover { filter: brightness(1.08); } + .btn-secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); } + + .pillars { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin: 2.2rem 0; } + .pillar { background: var(--surface); padding: 1.5rem 1.4rem; } + .pillar .picon { margin-bottom: 0.8rem; color: var(--accent); } + .pillar .picon .icon { width: 24px; height: 24px; } + .pillar h3 { font-size: 1rem; margin: 0 0 0.5rem; } + .pillar p { color: var(--text-dim); font-size: 0.88rem; margin: 0; } + + .split { display: grid; grid-template-columns: 1.1fr 1fr; gap: 2.2rem; align-items: center; margin: 2.8rem 0; } + .split h2 { font-size: 1.3rem; margin: 0 0 0.8rem; } + .split p { color: var(--text-dim); font-size: 0.92rem; margin: 0 0 0.9rem; } + .split .links { display: flex; gap: 1.3rem; flex-wrap: wrap; } + .split .links a { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--accent); text-decoration: none; font-size: 0.88rem; font-weight: 600; } + + .fed-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.4rem; } + .fed-diagram svg { width: 100%; height: auto; display: block; } + + .cta-band { text-align: center; padding: 2.4rem 1.4rem; margin: 2.6rem 0 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; } + .cta-band h2 { font-size: 1.25rem; margin: 0 0 0.5rem; } + .cta-band p { color: var(--text-dim); font-size: 0.9rem; margin: 0 0 1.2rem; } + + @media (max-width: 720px) { + .pillars, .split { grid-template-columns: 1fr; } + } + + /* ---------- security page ---------- */ + .sec-head { padding: 1.8rem 0 0.6rem; text-align: center; } + .sec-head .kicker { display: inline-flex; align-items: center; gap: 0.45rem; font-family: var(--mono); font-size: 0.76rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ok-fg); background: var(--ok-bg); border: 1px solid rgba(123,214,168,0.3); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1.1rem; } + .sec-head h1 { font-size: clamp(1.6rem, 3.6vw, 2.1rem); margin: 0 auto 0.7rem; max-width: 620px; } + .sec-head p { color: var(--text-dim); max-width: 540px; margin: 0 auto; font-size: 0.95rem; } + + .sec-section { margin: 2.6rem 0; padding-top: 2rem; border-top: 1px solid var(--border); } + .sec-section:first-of-type { border-top: none; } + .sec-section .tag { font-family: var(--mono); font-size: 0.72rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; } + .sec-section h2 { font-size: 1.15rem; margin: 0 0 0.7rem; display: flex; align-items: center; gap: 0.55rem; } + .sec-section h2 .icon { width: 19px; height: 19px; color: var(--accent); } + .sec-section > p { color: var(--text-dim); font-size: 0.92rem; max-width: 640px; margin: 0 0 1.2rem; } + .sec-diagram { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.4rem; margin-bottom: 1rem; overflow-x: auto; } + .sec-diagram svg { display: block; margin: 0 auto; min-width: 380px; } + .sec-diagram .cap { text-align: center; color: var(--text-faint); font-size: 0.78rem; margin-top: 0.8rem; } + .sec-facts { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.7rem; margin-top: 1rem; } + .sec-fact { background: var(--surface-2); border: 1px solid var(--border); border-radius: 9px; padding: 0.85rem 1rem; font-size: 0.86rem; color: var(--text-dim); display: flex; gap: 0.6rem; } + .sec-fact .icon { width: 15px; height: 15px; color: var(--ok-fg); flex-shrink: 0; margin-top: 0.15rem; } + .sec-fact strong { color: var(--text); font-weight: 600; } + @media (max-width: 640px) { .sec-facts { grid-template-columns: 1fr; } } + /* ---------- repo page ---------- */ .gf-crumbs { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); } .gf-crumbs a { color: var(--text-dim); text-decoration: none; } @@ -268,6 +385,7 @@ const shellSrc = `<!doctype html> </style> </head> <body> +{{.IconSprite}} <header class="gf-nav"> <a href="/" class="gf-nav-brand">{{.BrandMark}}<span>gitfed</span></a> <button class="gf-nav-burger" id="navBurger" aria-label="Toggle menu" aria-expanded="false" aria-controls="navPanel"> @@ -275,17 +393,23 @@ const shellSrc = `<!doctype html> </button> <div class="gf-nav-panel" id="navPanel"> <nav class="gf-nav-links"> - <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a> - {{if .LoggedIn}}<a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a>{{end}} + <a href="/"{{if eq .Active "home"}} class="active"{{end}}>{{t .Lang "nav.home"}}</a> + <a href="/explore"{{if eq .Active "explore"}} class="active"{{end}}>{{t .Lang "nav.explore"}}</a> + <a href="/security"{{if eq .Active "security"}} class="active"{{end}}>{{t .Lang "nav.security"}}</a> + {{if .LoggedIn}}<a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>{{t .Lang "nav.dashboard"}}</a>{{end}} </nav> <form class="gf-nav-search" action="/search" method="get" role="search"> <div class="gf-nav-search-inner"> <svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><line x1="14" y1="14" x2="18" y2="18"/></svg> - <input type="search" name="q" id="navSearch" placeholder="Search repos…" value="{{.SearchQuery}}" autocomplete="off"> + <input type="search" name="q" id="navSearch" placeholder="{{t .Lang "nav.search_placeholder"}}" value="{{.SearchQuery}}" autocomplete="off"> <kbd>⌘K</kbd> </div> </form> <div class="gf-nav-right"> + <div class="gf-lang"> + <a href="/lang/en?next={{.NextPath}}"{{if eq .Lang "en"}} class="active"{{end}}>EN</a> + <a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a> + </div> {{if .LoggedIn}} <div class="gf-profile"> <button class="gf-profile-trigger" id="profileTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="profileMenu"> @@ -294,26 +418,35 @@ const shellSrc = `<!doctype html> <span class="chev">▾</span> </button> <div class="gf-profile-menu" id="profileMenu"> - <div class="who">Signed in as <strong>{{.Username}}</strong></div> - <a href="/settings">⚙ Settings</a> - {{if .IsAdmin}}<a href="/admin">🛡 Admin</a>{{end}} + <div class="who">{{t .Lang "nav.signed_in_as"}} <strong>{{.Username}}</strong></div> + <a href="/settings">{{icon "settings"}} {{t .Lang "nav.settings"}}</a> + {{if .IsAdmin}}<a href="/admin">{{icon "shield"}} {{t .Lang "nav.admin"}}</a>{{end}} <div class="divider"></div> - <form method="post" action="/logout"><button class="danger" type="submit">↪ Log out</button></form> + <form method="post" action="/logout"><button class="danger" type="submit">{{icon "logout"}} {{t .Lang "nav.logout"}}</button></form> </div> </div> {{else}} - <a href="/login">Log in</a> + <a href="/login">{{t .Lang "nav.login"}}</a> {{end}} </div> </div> </header> -<main{{if .Wide}} class="wide"{{end}}> +<main> {{.Body}} </main> <footer> - <a href="/changelog">gitfed {{.Version}}</a> + <a href="/changelog">{{t .Lang "footer.changelog" .Version}}</a> </footer> -<script> +<script>` + shellScriptJS + `</script> +</body> +</html>` + +// shellScriptJS is the shell's inline script, kept as its own constant so its +// SHA-256 can be pinned in the Content-Security-Policy (see securityHeaders): +// with a strict script-src there is no 'unsafe-inline', so the browser only +// runs this block if its hash matches. Any edit here changes that hash, which +// is recomputed at init — the two never drift. +const shellScriptJS = ` (function () { var burger = document.getElementById('navBurger'); var panel = document.getElementById('navPanel'); @@ -346,9 +479,9 @@ const shellSrc = `<!doctype html> var btn = e.target.closest('[data-copy]'); if (!btn || !navigator.clipboard) return; navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () { - var orig = btn.textContent; - btn.textContent = '✓'; - setTimeout(function () { btn.textContent = orig; }, 1200); + var orig = btn.innerHTML; + btn.innerHTML = '<svg class="icon" aria-hidden="true"><use href="#ic-check"/></svg>'; + setTimeout(function () { btn.innerHTML = orig; }, 1200); }); }); document.addEventListener('click', function (e) { @@ -361,32 +494,36 @@ const shellSrc = `<!doctype html> var panes = group.parentElement.querySelectorAll('.gf-tabpane'); panes.forEach(function (p) { p.classList.toggle('active', p.id === tab.getAttribute('data-tab')); }); }); + // Delegated confirm for destructive forms, replacing inline onsubmit + // handlers (which a strict CSP script-src would block). + document.addEventListener('submit', function (e) { + var f = e.target.closest('form[data-confirm]'); + if (f && !window.confirm(f.getAttribute('data-confirm'))) { + e.preventDefault(); + } + }); })(); -</script> -</body> -</html>` +` -var shellTpl = template.Must(template.New("shell").Parse(shellSrc)) +// shellSrc is the full page shell: the static head/body markup (with template +// actions) followed by the inline script, concatenated so the bytes between +// <script>…</script> are exactly shellScriptJS and match the CSP hash. +const shellSrc = shellHeadSrc -func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) { - s.renderOpt(w, r, title, active, body, false) -} - -// renderWide is render, but the page body isn't capped at the usual -// reading-width column — for pages like the repo file browser where a -// narrow column just wastes space on file/README content. -func (s *server) renderWide(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) { - s.renderOpt(w, r, title, active, body, true) -} +var shellTpl = newTpl("shell", shellSrc) -func (s *server) renderOpt(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML, wide bool) { +func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) { sess, loggedIn := s.currentSession(r) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = shellTpl.Execute(w, struct { - Title, Domain, Active, Username, Version, Initials, SearchQuery string - LoggedIn, IsAdmin, Wide bool - Body, BrandMark template.HTML - }{title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), loggedIn, sess.IsAdmin, wide, body, template.HTML(brandMark)}) + Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string + LoggedIn, IsAdmin bool + Body, BrandMark, IconSprite template.HTML + }{ + title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), + string(s.lang(r)), r.URL.RequestURI(), + loggedIn, sess.IsAdmin, body, template.HTML(brandMark), template.HTML(iconSprite), + }) } // initials turns a username into a one-or-two-letter avatar label: @@ -424,6 +561,14 @@ func flash(r *http.Request) template.HTML { return template.HTML(`<div class="msg ` + class + `">` + template.HTMLEscapeString(msg) + `</div>`) } +// serverError logs the real error server-side and returns a generic message +// to the client, so internal details (store paths, wrapped errors) never leak +// into an HTTP response body. +func (s *server) serverError(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("gitfed-web: %s %s: %v", r.Method, r.URL.Path, err) + http.Error(w, i18n.T(s.lang(r), "common.server_error"), http.StatusInternalServerError) +} + func redirectWithMsg(w http.ResponseWriter, r *http.Request, path, msg string, isErr bool) { sep := "?" if strings.Contains(path, "?") {
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 62d5117..158c52f 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -4,7 +4,7 @@ import "net/http" func (s *server) routes(mux *http.ServeMux) { // Public — no login required. - mux.HandleFunc("GET /{$}", s.handleHome) + mux.HandleFunc("GET /{$}", s.handleLanding) mux.HandleFunc("GET /r/{repo...}", s.handleRepoView) mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob) mux.HandleFunc("GET /login", s.handleLoginForm) @@ -12,6 +12,9 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /changelog", s.handleChangelog) mux.HandleFunc("GET /search", s.handleSearch) + mux.HandleFunc("GET /security", s.handleSecurity) + mux.HandleFunc("GET /explore", s.handleExplore) + mux.HandleFunc("GET /lang/{lang}", s.handleSetLang) // Self-service — any logged-in user, scoped to their own stuff via // CheckAccess inside the handlers.
cmd/gitfed-web/security_headers.go
diff --git a/cmd/gitfed-web/security_headers.go b/cmd/gitfed-web/security_headers.go new file mode 100644 index 0000000..07fc25b --- /dev/null +++ b/cmd/gitfed-web/security_headers.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "html/template" + "net/http" + "net/url" + "strings" +) + +// scriptHash is the CSP source expression pinning the shell's inline script. +// It's computed from the *rendered* shell rather than the raw shellScriptJS +// constant: html/template rewrites some characters (e.g. "<") inside a +// <script> element as breakout protection, so only the emitted bytes are what +// the browser actually hashes. Deriving it here means it can never drift from +// what's served. +var scriptHash = "sha256-" + func() string { + sum := sha256.Sum256([]byte(renderedShellScript())) + return base64.StdEncoding.EncodeToString(sum[:]) +}() + +// renderedShellScript executes the shell template and returns the exact bytes +// between <script> and </script> — the content the CSP hash must cover. +func renderedShellScript() string { + var buf bytes.Buffer + _ = shellTpl.Execute(&buf, struct { + Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string + LoggedIn, IsAdmin bool + Body, BrandMark, IconSprite template.HTML + }{Lang: "en"}) + html := buf.String() + open := strings.Index(html, "<script>") + end := strings.Index(html, "</script>") + if open < 0 || end < 0 || end < open { + return "" + } + return html[open+len("<script>") : end] +} + +// contentSecurityPolicy is deliberately strict: no 'unsafe-inline' for +// scripts (the one inline block is allowed by its hash), everything else +// same-origin, framing forbidden. style-src keeps 'unsafe-inline' because the +// templates rely on inline style="" attributes throughout — those carry no +// script and can't be nonce/hash-pinned, so this is the tightest workable +// policy without a template-wide refactor. img-src allows data: for the +// inline SVG favicons and markdown images. +func contentSecurityPolicy() string { + return strings.Join([]string{ + "default-src 'self'", + "script-src 'self' '" + scriptHash + "'", + "style-src 'self' 'unsafe-inline'", + // https: lets README images/badges load; still blocks http: and + // other schemes. data: covers the inline SVG favicons. + "img-src 'self' data: https:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + }, "; ") +} + +// securityHeaders sets the standard hardening response headers on every +// response. HSTS is safe to always emit: the app is only reachable over TLS +// in production (the ingress terminates it) and browsers ignore the header on +// plain-HTTP responses. +func securityHeaders(next http.Handler) http.Handler { + csp := contentSecurityPolicy() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", csp) + h.Set("X-Frame-Options", "DENY") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + next.ServeHTTP(w, r) + }) +} + +// sameOriginPOST rejects state-changing requests whose Origin (or, failing +// that, Referer) is not this same site. Combined with the SameSite=Lax +// session cookie, this is defense-in-depth against CSRF that needs no +// per-form token: browsers attach Origin to form POSTs, and a cross-site +// attacker cannot forge it. A request that carries neither header on an +// unsafe method is refused rather than trusted. +func (s *server) sameOriginPOST(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + next.ServeHTTP(w, r) + return + } + if !sameOrigin(r) { + http.Error(w, "cross-origin request refused", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func sameOrigin(r *http.Request) bool { + source := r.Header.Get("Origin") + if source == "" { + source = r.Header.Get("Referer") + } + if source == "" { + return false + } + u, err := url.Parse(source) + if err != nil || u.Host == "" { + return false + } + return strings.EqualFold(u.Host, r.Host) +}
cmd/gitfed-web/security_headers_test.go
diff --git a/cmd/gitfed-web/security_headers_test.go b/cmd/gitfed-web/security_headers_test.go new file mode 100644 index 0000000..7be49ae --- /dev/null +++ b/cmd/gitfed-web/security_headers_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "html/template" + "net/http/httptest" + "strings" + "testing" +) + +// TestCSPScriptHashMatchesRenderedScript renders the shell and checks that the +// CSP hash we advertise equals the hash of the script actually served — if a +// future edit to the shell changes either the script or how it's emitted, this +// fails instead of silently breaking every page under the strict policy. +func TestCSPScriptHashMatchesRenderedScript(t *testing.T) { + var buf bytes.Buffer + err := shellTpl.Execute(&buf, struct { + Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string + LoggedIn, IsAdmin bool + Body, BrandMark, IconSprite template.HTML + }{Title: "t", Lang: "en"}) + if err != nil { + t.Fatalf("execute shell: %v", err) + } + + html := buf.String() + open := strings.Index(html, "<script>") + close := strings.Index(html, "</script>") + if open < 0 || close < 0 || close < open { + t.Fatalf("no <script>…</script> in rendered shell") + } + served := html[open+len("<script>") : close] + + sum := sha256.Sum256([]byte(served)) + want := "sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + if want != scriptHash { + t.Fatalf("CSP script hash mismatch:\n advertised %s\n served %s", scriptHash, want) + } +} + +func TestSameOriginPOST(t *testing.T) { + s := &server{} + handler := s.sameOriginPOST(nil) + _ = handler // constructed to ensure it wraps without panicking + + cases := []struct { + name string + method string + origin string + referer string + host string + wantOrigin bool + }{ + {"get always passes", "GET", "", "", "example.com", true}, + {"matching origin", "POST", "https://example.com", "", "example.com", true}, + {"mismatched origin", "POST", "https://evil.com", "", "example.com", false}, + {"referer fallback match", "POST", "", "https://example.com/login", "example.com", true}, + {"no headers refused", "POST", "", "", "example.com", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(c.method, "http://example.com/x", nil) + r.Host = c.host + if c.origin != "" { + r.Header.Set("Origin", c.origin) + } + if c.referer != "" { + r.Header.Set("Referer", c.referer) + } + got := c.method == "GET" || sameOrigin(r) + if got != c.wantOrigin { + t.Fatalf("got allowed=%v, want %v", got, c.wantOrigin) + } + }) + } +}
cmd/gitfed-web/session.go
diff --git a/cmd/gitfed-web/session.go b/cmd/gitfed-web/session.go index 07c68ad..8cff453 100644 --- a/cmd/gitfed-web/session.go +++ b/cmd/gitfed-web/session.go @@ -18,7 +18,7 @@ func setSessionCookie(w http.ResponseWriter, token string) { HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(30 * 24 * time.Hour), + Expires: time.Now().Add(7 * 24 * time.Hour), // matches admin.sessionTTL }) }
deploy/k8s/configmap.yaml
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index 3f25480..1981330 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -16,6 +16,6 @@ data: "listen_http": ":8443", "contact": "bastien.marques@outlook.com", "trust_policy": "whitelist", - "cert_ttl_hours": 48, + "cert_ttl_hours": 24, "insecure_federation": false }
deploy/k8s/deployment.yaml
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index bdc11dc..3a311ee 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -59,10 +59,19 @@ spec: - name: config mountPath: /etc/gitfed readOnly: true + - name: tmp + mountPath: /tmp + - name: home + mountPath: /home/gitfed securityContext: runAsNonRoot: true runAsUser: 1000 allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault resources: requests: {cpu: 20m, memory: 32Mi} limits: {cpu: 300m, memory: 256Mi} @@ -92,10 +101,19 @@ spec: - name: config mountPath: /etc/gitfed readOnly: true + - name: web-tmp + mountPath: /tmp + - name: web-home + mountPath: /home/gitfed securityContext: runAsNonRoot: true runAsUser: 1000 allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault resources: requests: {cpu: 10m, memory: 32Mi} limits: {cpu: 200m, memory: 128Mi} @@ -110,3 +128,14 @@ spec: - name: config configMap: name: gitfed-config + # Writable scratch for the read-only root filesystem: git and the + # runtime need a writable /tmp and $HOME even though the rootfs is + # locked down. Per-container so the two never share scratch state. + - name: tmp + emptyDir: {} + - name: home + emptyDir: {} + - name: web-tmp + emptyDir: {} + - name: web-home + emptyDir: {}
deploy/k8s/networkpolicy.yaml
diff --git a/deploy/k8s/networkpolicy.yaml b/deploy/k8s/networkpolicy.yaml new file mode 100644 index 0000000..31cc3b8 --- /dev/null +++ b/deploy/k8s/networkpolicy.yaml @@ -0,0 +1,45 @@ +# Egress hardening: gitfed only ever needs to reach the public internet (to +# fetch other instances' /.well-known/gitfed.json during federation) plus +# cluster DNS. This policy blocks outbound traffic to private / internal +# ranges, so even if the application-level anti-SSRF guard (see +# internal/federation/wellknown.go) were bypassed, a crafted federation target +# still couldn't reach cluster-internal or link-local (cloud-metadata) +# addresses. Ingress is left to the ingress controller / Service. +# +# NOTE: requires a CNI that enforces NetworkPolicy (Cilium, Calico, ...). +# k3s' default flannel does NOT enforce it — deploy a policy-capable CNI or +# treat this as documentation of intent. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: gitfed-egress + namespace: gitfed +spec: + podSelector: + matchLabels: + app: gitfed + policyTypes: [Egress] + egress: + # Cluster DNS resolution. + - to: + - namespaceSelector: {} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Public internet on 443 (federation well-known fetch), excluding all + # private, loopback, link-local and CGNAT ranges. + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.0.0/16 + - 127.0.0.0/8 + - 100.64.0.0/10 + ports: + - protocol: TCP + port: 443
docs/security/AUDIT.md
diff --git a/docs/security/AUDIT.md b/docs/security/AUDIT.md new file mode 100644 index 0000000..5ebe574 --- /dev/null +++ b/docs/security/AUDIT.md @@ -0,0 +1,214 @@ +# Rapport d'audit de sécurité — gitfed + +- **Date** : 2026-07-28 +- **Périmètre** : audit défensif complet avant mise en production, avec focus sur l'authentification et la certification (fédération / CA / SSH). +- **Cible** : dépôt `gitfed` (branche `main`), commit `523f049` (Deploy v0.6.0). +- **Nature** : revue de code statique + inspection de la configuration de déploiement (Docker / Kubernetes). + +--- + +## 1. Synthèse + +Le cœur de sécurité du projet est **sain** : + +- **Aucune backdoor** détectée. +- La chaîne de confiance fédérée est **cryptographiquement vérifiée** : `CertChecker.CheckCert` vérifie la signature du certificat (`golang.org/x/crypto/ssh/certs.go:456-459`), et le code **épingle la clé d'autorité** avant vérification (`internal/ssh/server.go:148-170`). +- **Aucun secret commité** : le dossier `demo/` (clés CA, host keys, DB) est bien couvert par `.gitignore` et non suivi par git. +- **Pas d'injection shell** : git est invoqué via `exec.Command` sans passer par un shell (`internal/gitexec/gitexec.go`). +- **Pas de XSS stocké** : `html/template` échappe automatiquement, et goldmark (sans `WithUnsafe`) filtre le HTML brut **et** les URLs dangereuses (`javascript:`, `data:`, `vbscript:`) — vérifié dans `goldmark@v1.8.4/renderer/html/html.go:518`. +- Mots de passe hachés en **bcrypt**, jamais renvoyés par l'API (bucket séparé `auth`). + +Les vulnérabilités identifiées sont des **durcissements classiques d'avant-production**, pas des failles critiques exploitables à distance sans conditions. La plus prioritaire est l'absence de limitation des tentatives de connexion web. + +### Tableau récapitulatif + +| ID | Sévérité | Titre | Fichier principal | +|-----|----------|-------------------------------------------------------------|-------------------| +| H1 | 🔴 High | Aucune limitation des tentatives de login web | `cmd/gitfed-web/handlers_auth.go:41` | +| M1 | 🟠 Medium | SSRF via la découverte de fédération | `internal/federation/wellknown.go:47` | +| M2 | 🟠 Medium | Pas de révocation de certificat | `internal/ssh/server.go:130` | +| M3 | 🟠 Medium | En-têtes HTTP de sécurité absents | `cmd/gitfed-web/render.go` | +| M4 | 🟠 Medium | `gitfed-renew-cert` : host key non vérifié par défaut | `cmd/gitfed-renew-cert/main.go:84` | +| M5 | 🟠 Medium | Timeouts HTTP absents + corps de réponse non borné | `cmd/gitfed-web/main.go:55`, `internal/federation/wellknown.go:53` | +| L1 | 🟡 Low | Énumération d'utilisateurs par timing | `internal/admin/admin.go:332` | +| L2 | 🟡 Low | Fuite de messages d'erreur internes vers l'utilisateur | multiples handlers web | +| L3 | 🟡 Low | Session : TTL 30 j, pas de rotation, pas de purge | `internal/admin/admin.go:353`, `internal/store/sessions.go` | +| L4 | 🟡 Low | bcrypt coût 10 ; politique de mot de passe minimale | `internal/admin/admin.go:312-322` | +| L5 | 🟡 Low | Squat de namespace de repo | `cmd/gitfed-web/handlers_dashboard.go:88` | +| L6 | 🟡 Low | Pas de quota repos/utilisateurs (DoS disque) | `internal/admin/admin.go:186` | +| L7 | 🟡 Low | Durcissement conteneur K8s incomplet | `deploy/k8s/deployment.yaml` | + +--- + +## 2. Détail des vulnérabilités + +### 🔴 H1 — Aucune limitation des tentatives de connexion web + +**Fichier** : `cmd/gitfed-web/handlers_auth.go:41` (`handleLogin`) + +Le formulaire de login est exposé sur Internet (ingress `/`) sans **rate-limiting**, sans **lockout de compte**, sans **délai** ni captcha. bcrypt (coût 10) ralentit chaque essai, mais un brute-force en ligne ciblé reste réalisable. + +**Aggravant architectural** : le socket admin (`/data/admin.sock`) est un accès « god-mode » **sans authentification** (protégé uniquement par le mode fichier `0600`). Le conteneur `web` détient donc toute la capacité admin de l'instance ; les gardes de route (`requireLogin` / `requireAdmin`) sont la **seule** barrière entre un visiteur anonyme et le contrôle total. Toute faiblesse du login en amplifie l'impact. + +**Impact** : compromission de compte (y compris admin) par force brute / bourrage d'identifiants. + +--- + +### 🟠 M1 — SSRF via la découverte de fédération + +**Fichiers** : `internal/federation/wellknown.go:47` (`Fetch`), `internal/admin/admin.go:291` (`GrantCollaborator`), `internal/federation/resolver.go:63` (`EnsureTrust`) + +`Fetch(domain)` construit `https://<domain>/.well-known/gitfed.json` et effectue une requête HTTP sortante. Le `domain` provient de `GrantCollaborator`, exposé via `POST /repo-grant/{repo}` à **tout propriétaire de repo** — pas uniquement à un admin d'instance (la garde est `canAdminister` sur le repo, `handlers_repo.go:441`). + +Un utilisateur authentifié peut donc ajouter un collaborateur du type `x@169.254.169.254` ou `x@10.0.0.5:6379` et déclencher une requête vers une IP interne ou l'endpoint de métadonnées cloud. + +**Limitations pour l'attaquant** : +- SSRF « aveugle » : seul le succès/échec fuit (via le log d'audit et le timing) ; la réponse est validée (`doc.Domain == domain` + `ca_public_key` requis) et n'est pas réfléchie. +- Rate-limité à 10 nouvelles découvertes/minute (`resolver.go:33`). + +**Manques** : aucune validation du format de domaine, aucun blocage des IP littérales / plages privées / link-local / metadata, pas de protection anti-DNS-rebinding. + +**Impact** : scan de ports interne, sondage de services internes, atteinte potentielle aux métadonnées cloud. + +--- + +### 🟠 M2 — Pas de révocation de certificat + +**Fichiers** : `internal/ssh/server.go:130` (`checkCert`), `internal/ca/ca.go:101` (`IssueUserCert`) + +Un certificat émis reste valide jusqu'à son expiration (**48 h** par défaut, `config.go:39`) même après : +- la suppression de l'utilisateur (`DeleteUser`), +- le retrait de la clé publique certifiée. + +`checkCert` ne valide que la signature CA et le format du principal — il ne vérifie **jamais** que l'utilisateur ou la clé existent encore côté serveur. `CertChecker` est instancié sans `IsRevoked` (`server.go:167`), donc aucune KRL/CRL n'est consultée. + +À l'usage, un utilisateur supprimé conserve, tant que son cert est valide, l'accès en lecture aux repos publics (l'autorisation par repo via `acl.Check` limite le reste). Mais la **révocation immédiate** d'un compte compromis est impossible. + +**Impact** : fenêtre de révocation jusqu'à 48 h ; pas de kill-switch pour une clé/compte compromis. + +--- + +### 🟠 M3 — En-têtes HTTP de sécurité absents + +**Fichier** : `cmd/gitfed-web/render.go`, ensemble du serveur web + +Aucun en-tête de sécurité n'est émis : +- `Content-Security-Policy` (pertinent : le shell contient un `<script>` inline, `render.go:439`), +- `X-Frame-Options: DENY` / `frame-ancestors` → **clickjacking** possible sur les formulaires admin (make-admin, delete-user, approve-domain), +- `X-Content-Type-Options: nosniff`, +- `Referrer-Policy`, +- `Strict-Transport-Security` (HSTS). + +**Impact** : clickjacking, absence de défense en profondeur contre l'exécution de contenu injecté, fuite de referrer. + +--- + +### 🟠 M4 — `gitfed-renew-cert` : host key non vérifié par défaut + +**Fichier** : `cmd/gitfed-renew-cert/main.go:84` + +Sans l'option `-host-key`, l'outil utilise `gossh.InsecureIgnoreHostKey()`. Il émet un avertissement mais poursuit la connexion. Un attaquant en position de MITM peut intercepter le renouvellement de certificat. + +**Impact** : interception du canal de renouvellement ; usurpation de l'instance d'origine. Outil côté client, mais distribué avec le projet et documenté comme point d'entrée officiel. + +--- + +### 🟠 M5 — Timeouts HTTP absents + corps de réponse non borné + +**Fichiers** : `cmd/gitfed-web/main.go:55`, `cmd/gitfed-server/main.go:127`, `internal/federation/wellknown.go:53` + +- Les serveurs web et well-known utilisent `http.ListenAndServe` sans `ReadHeaderTimeout` / `ReadTimeout` / `WriteTimeout` / `IdleTimeout` → exposition **Slowloris**. +- `Fetch` décode `resp.Body` via `json.NewDecoder` sans `io.LimitReader` → un pair distant malveillant peut renvoyer un corps arbitrairement grand (DoS mémoire pendant la découverte). + +**Impact** : épuisement de connexions / mémoire. + +--- + +### 🟡 L1 — Énumération d'utilisateurs par timing + +**Fichier** : `internal/admin/admin.go:332` (`VerifyPassword`) + +Quand l'utilisateur ou le hash est absent, la fonction retourne **sans** appeler bcrypt. La différence de temps de réponse permet de distinguer un utilisateur existant d'un inexistant. + +**Correctif** : comparer systématiquement contre un hash bcrypt factice. + +--- + +### 🟡 L2 — Fuite de messages d'erreur internes + +**Fichiers** : `handlers_auth.go:49,60`, `handlers_settings.go`, handlers admin + +Plusieurs chemins renvoient `err.Error()` brut à l'utilisateur, pouvant divulguer des détails internes (chemins, structure du store). + +**Correctif** : messages génériques côté client, détail journalisé côté serveur. + +--- + +### 🟡 L3 — Gestion de session + +**Fichiers** : `internal/admin/admin.go:353` (`sessionTTL = 30 j`), `internal/store/sessions.go` + +- TTL de **30 jours**, sans rotation à l'élévation de privilège, sans expiration d'inactivité. +- Les sessions expirées ne sont supprimées que **paresseusement** (à l'accès), pas purgées — croissance non bornée du bucket. + +--- + +### 🟡 L4 — Politique de mot de passe / coût bcrypt + +**Fichier** : `internal/admin/admin.go:312-322` + +- bcrypt `DefaultCost` (10) ; recommandation 2026 : **12**. +- Longueur minimale 8, sans vérification de mot de passe compromis. + +--- + +### 🟡 L5 — Squat de namespace de repo + +**Fichier** : `cmd/gitfed-web/handlers_dashboard.go:88` (`handleCreateRepo`) + +Le nom du repo est libre : un utilisateur `alice` peut créer `bob/x`. L'owner reste `alice@domain` (pas d'usurpation d'identité), mais le nom est trompeur. Le path traversal est bien bloqué (`ResolvePath` rejette `..`). + +--- + +### 🟡 L6 — Pas de quota + +**Fichier** : `internal/admin/admin.go:186` (`CreateRepo`) + +Aucun plafond sur le nombre de repos/utilisateurs. Chaque repo déclenche un `git init --bare` → DoS disque possible par un utilisateur authentifié. + +--- + +### 🟡 L7 — Durcissement conteneur K8s incomplet + +**Fichier** : `deploy/k8s/deployment.yaml` + +Bon socle (non-root, `allowPrivilegeEscalation: false`, limites de ressources). Manquent : +- `readOnlyRootFilesystem: true`, +- `capabilities: { drop: [ALL] }`, +- `seccompProfile: { type: RuntimeDefault }`, +- une `NetworkPolicy` restreignant les flux sortants (renforce M1). + +--- + +## 3. Contrôles vérifiés et jugés sains + +| Domaine | Constat | +|---------|---------| +| Certificats | Signature vérifiée + autorité épinglée (`server.go:163-170`) | +| Fédération | Réponse well-known validée (domaine + clé CA présente) | +| Injection | git via `exec.Command` sans shell ; noms de repo validés par regex + rejet `..` | +| Path traversal | Chemins de fichiers bornés par git (`git show ref:path`) | +| XSS | `html/template` + goldmark filtre HTML brut et URLs dangereuses | +| Open redirect | `sanitizeNext` restreint `next` aux chemins locaux | +| CSRF | Cookies `HttpOnly` + `Secure` + `SameSite=Lax` (couvre le POST cross-site) | +| Secrets au repos | Clés CA / host / DB / socket en `0600` ; `demo/` gitignoré | +| Conteneurs | Exécution non-root, privilège non escaladable | +| TLS | Terminaison Traefik + cert-manager (Let's Encrypt) en prod | + +> **Prérequis de déploiement à documenter** : le endpoint well-known est servi en **HTTP clair** sur `:8443` et dépend entièrement de la terminaison TLS de l'ingress. En Kubernetes c'est correct (`insecure_federation: false`), mais toute exécution hors de ce chemin ferait transiter la clé publique CA en clair (MITM sur la découverte de fédération). Ne jamais exposer `:8443` directement sur Internet sans TLS devant. + +--- + +## 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).
docs/security/FIX_PLAN.md
diff --git a/docs/security/FIX_PLAN.md b/docs/security/FIX_PLAN.md new file mode 100644 index 0000000..4914817 --- /dev/null +++ b/docs/security/FIX_PLAN.md @@ -0,0 +1,192 @@ +# Plan d'implémentation des correctifs de sécurité — gitfed + +Référence : `docs/security/AUDIT.md` (audit du 2026-07-28). + +> **État : ✅ intégralement implémenté (2026-07-28).** Les 12 tâches (T1–T12) +> sont livrées. `go build ./...`, `go vet ./...` et `go test ./...` passent. +> Tests ajoutés : hash CSP vs script rendu, same-origin POST, validation de +> domaine anti-SSRF, blocage d'IP privées, révocation (store + admin). +> +> | Tâche | État | Fichiers clés | +> |-------|------|---------------| +> | T1 rate-limit login | ✅ | `cmd/gitfed-web/ratelimit.go`, `handlers_auth.go`, `main.go` | +> | T2 anti-SSRF fédération | ✅ | `internal/federation/wellknown.go` (+ `ssrf_test.go`) | +> | T3 en-têtes + CSP | ✅ | `cmd/gitfed-web/security_headers.go`, `render.go` | +> | T4 timeouts + corps borné | ✅ | `cmd/gitfed-web/main.go`, `cmd/gitfed-server/main.go`, `wellknown.go` | +> | T5 révocation de cert | ✅ | `internal/store/revocations.go`, `ssh/server.go`, `admin/admin.go`, `config.go` | +> | T6 host key obligatoire | ✅ | `cmd/gitfed-renew-cert/main.go` | +> | T7 bcrypt temps constant | ✅ | `internal/admin/admin.go` (dummyHash) | +> | T8 erreurs génériques + CSRF | ✅ | `render.go` (serverError), `security_headers.go` (sameOriginPOST) | +> | T9 sessions | ✅ | `internal/store/sessions.go`, `admin.go`, `cmd/gitfed-server/main.go`, `session.go` | +> | T10 bcrypt coût 12 | ✅ | `internal/admin/admin.go` | +> | T11 namespace + quota | ✅ | `cmd/gitfed-web/handlers_dashboard.go` | +> | T12 durcissement K8s | ✅ | `deploy/k8s/deployment.yaml`, `networkpolicy.yaml`, `configmap.yaml` | +> +> **Choix de conception à noter :** +> - **CSRF (T8)** : implémenté par vérification d'origine (`Origin`/`Referer`) +> sur les méthodes non-sûres plutôt que par jetons par formulaire — robuste, +> uniforme, sans toucher les 15 formulaires. En complément de `SameSite=Lax`. +> - **CSP (T3)** : le script inline est autorisé par son **hash SHA-256** +> (calculé sur le script *rendu*, pas la constante brute, car html/template +> réécrit `<` dans un `<script>`). `style-src 'unsafe-inline'` reste requis +> pour les attributs `style="..."` omniprésents. `img-src` autorise `https:` +> pour ne pas casser les images de README. +> - **SSRF (T2)** : double barrière — validation du format de domaine + blocage +> des IP non-publiques **au moment du dial** (avec pinning de l'IP validée, +> anti-DNS-rebinding). Désactivée uniquement en mode `insecure_federation` +> (dev/loopback). +> - **NetworkPolicy (T12)** : nécessite un CNI qui l'applique (Cilium/Calico) ; +> flannel de k3s ne l'applique pas — sinon elle vaut documentation d'intention. +> +> Ce qui suit est le plan d'origine, conservé pour la traçabilité. + +Le plan est découpé en **3 lots** par priorité décroissante. Chaque tâche indique le fichier, l'approche, le test de validation et une estimation d'effort (S = < 1 h, M = 1–3 h, L = > 3 h). + +Ordre de traitement recommandé : **Lot 1 en entier** (bloquant prod) → **Lot 2** → **Lot 3**. + +--- + +## Lot 1 — Bloquant avant mise en production + +### T1 — Rate-limiting du login web *(H1 — effort M)* + +**Fichiers** : `cmd/gitfed-web/handlers_auth.go`, nouveau `cmd/gitfed-web/ratelimit.go` + +- Implémenter un limiteur in-memory (map protégée par mutex, ou `golang.org/x/time/rate` par clé) indexé sur **(IP source, nom d'utilisateur)**. +- Politique : back-off progressif après N échecs (ex. 5 essais / 15 min par compte, 20 / 15 min par IP), avec réponse générique et délai constant. +- Extraire l'IP réelle derrière l'ingress via `X-Forwarded-For` (en ne faisant confiance qu'au dernier proxy) ou `X-Real-IP`. +- Journaliser chaque échec dans l'audit (`AppendAudit`, action `web-login`, `Allowed:false`). +- Purge périodique des entrées expirées. + +**Validation** : test unitaire simulant N+1 tentatives → la (N+1)ᵉ est refusée ; une IP différente n'est pas affectée après le lockout d'un compte. + +--- + +### T2 — Anti-SSRF sur la découverte de fédération *(M1 — effort M)* + +**Fichiers** : `internal/federation/wellknown.go`, `internal/admin/admin.go` + +1. **Valider le domaine** avant tout accès réseau (`GrantCollaborator` et `Fetch`) : + - hostname RFC-conforme uniquement ; rejeter les IP littérales ; refuser un port autre que le port implicite HTTPS (ou n'autoriser que le 443). +2. **Bloquer les cibles internes** via un `net.Dialer.Control` (ou `DialContext` custom) sur le `http.Client` : après résolution, refuser toute connexion vers une IP privée / loopback / link-local / metadata (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16`, `::1`, `fc00::/7`, `fe80::/10`, `100.64/10`). Contrôle appliqué **sur l'IP effectivement composée** → protège aussi du DNS-rebinding. +3. **Restreindre la surface** : n'autoriser la découverte d'un **nouveau** domaine que pour un admin d'instance (les propriétaires de repo non-admin ne déclenchent pas de fetch sortant), ou passer toute nouvelle découverte par la file d'approbation admin existante. + +**Validation** : tests table-driven — `169.254.169.254`, `localhost`, `10.0.0.1`, `[::1]`, domaine avec port arbitraire → tous rejetés ; domaine public valide → autorisé. Test anti-rebinding : nom résolvant vers une IP privée → refusé au dial. + +--- + +### T3 — En-têtes HTTP de sécurité *(M3 — effort S)* + +**Fichiers** : nouveau `cmd/gitfed-web/security_headers.go`, `cmd/gitfed-web/main.go` + +- Middleware enveloppant le mux : + - `Content-Security-Policy` stricte. Le `<script>` inline du shell impose soit un **nonce** par requête (injecté dans le template et la CSP), soit un **hash** du script. Préférer le nonce. + - `X-Frame-Options: DENY` (+ `frame-ancestors 'none'` dans la CSP). + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin`. + - `Strict-Transport-Security: max-age=31536000; includeSubDomains` (émis seulement derrière TLS). + +**Validation** : test HTTP vérifiant la présence de chaque en-tête ; contrôle manuel via `curl -I` ; page fonctionne sans erreur CSP en console. + +--- + +### T4 — Timeouts serveur + corps de réponse borné *(M5 — effort S)* + +**Fichiers** : `cmd/gitfed-web/main.go`, `cmd/gitfed-server/main.go`, `internal/federation/wellknown.go` + +- Remplacer les deux `http.ListenAndServe(...)` par un `&http.Server{ ReadHeaderTimeout: 5s, ReadTimeout: 15s, WriteTimeout: 30s, IdleTimeout: 60s, Handler: mux }`. +- Dans `Fetch`, envelopper le corps : `io.LimitReader(resp.Body, 1<<20)` (1 Mo) avant `json.NewDecoder`. + +**Validation** : `go build` ; test qu'un corps > 1 Mo est tronqué/rejeté proprement. + +--- + +## Lot 2 — Renforcement + +### T5 — Révocation de certificat *(M2 — effort L)* + +**Fichiers** : `internal/store/` (nouveau bucket `revocations`), `internal/ssh/server.go`, `internal/admin/admin.go` + +- Ajouter une liste de révocation persistée (par `KeyId` de cert et/ou par empreinte de clé publique + horodatage). +- Renseigner `CertChecker.IsRevoked` dans `checkCert` pour la consulter. +- Révoquer automatiquement à `DeleteUser` et `RemoveUserKey` (invalider les certs portant la clé retirée). +- Réduire le TTL par défaut à **12–24 h** (`config.go` / `InstanceMeta`). +- À défaut d'implémenter la KRL immédiatement : **documenter** explicitement la fenêtre de 48 h dans le README d'exploitation. + +**Validation** : émettre un cert, révoquer, vérifier que l'auth SSH est refusée ; vérifier qu'un cert non révoqué passe toujours. + +--- + +### T6 — `gitfed-renew-cert` : host key obligatoire *(M4 — effort S)* + +**Fichier** : `cmd/gitfed-renew-cert/main.go` + +- Faire échouer si `-host-key` est absent (au lieu d'`InsecureIgnoreHostKey`), **ou** implémenter un TOFU persistant (known_hosts local) avec avertissement à la première connexion seulement. +- Documenter la récupération de l'empreinte du host key de l'instance. + +**Validation** : sans `-host-key` → refus ; avec la bonne clé → succès ; avec une mauvaise clé → refus. + +--- + +### T7 — Comparaison bcrypt en temps constant *(L1 — effort S)* + +**Fichier** : `internal/admin/admin.go` (`VerifyPassword`) + +- Quand l'utilisateur ou le hash est absent, comparer le mot de passe fourni contre un **hash bcrypt factice** préchargé, puis retourner l'échec — pour égaliser le temps de réponse. + +**Validation** : test comparant grossièrement les temps de réponse utilisateur existant vs inexistant. + +--- + +### T8 — Messages d'erreur génériques + jetons CSRF (défense en profondeur) *(L2 — effort M)* + +**Fichiers** : handlers web, nouveau helper CSRF + +- Mapper les erreurs internes vers des messages utilisateurs génériques ; journaliser le détail côté serveur. +- Ajouter un jeton CSRF (double-submit cookie ou jeton lié à la session) sur les formulaires POST, en priorité les actions admin (make-admin, delete-user, approve-domain). + +**Validation** : POST sans jeton valide → rejeté ; parcours normal → OK. + +--- + +## Lot 3 — Défense en profondeur + +### T9 — Durcissement des sessions *(L3 — effort S)* + +**Fichiers** : `internal/admin/admin.go`, `internal/store/sessions.go` + +- Réduire `sessionTTL` (ex. 7 j) ; rotation du token à l'élévation de privilège. +- Tâche de purge périodique des sessions expirées. + +### T10 — Politique de mot de passe *(L4 — effort S)* + +**Fichier** : `internal/admin/admin.go` + +- Passer bcrypt au coût **12**. +- Optionnel : contrôle contre une liste de mots de passe communs. + +### T11 — Namespace & quotas *(L5/L6 — effort M)* + +**Fichiers** : `internal/admin/admin.go`, `cmd/gitfed-web/handlers_dashboard.go` + +- Préfixer/valider le nom de repo par l'utilisateur créateur, ou réserver le namespace. +- Introduire un quota configurable de repos par utilisateur. + +### T12 — Durcissement K8s *(L7 — effort S)* + +**Fichier** : `deploy/k8s/deployment.yaml`, nouveau `deploy/k8s/networkpolicy.yaml` + +- Ajouter `readOnlyRootFilesystem: true` (+ `emptyDir` pour les chemins temporaires si besoin), `capabilities: { drop: [ALL] }`, `seccompProfile: { type: RuntimeDefault }`. +- `NetworkPolicy` limitant l'egress (renforce T2 contre la SSRF). + +--- + +## Séquencement suggéré + +``` +Sprint 1 (prod-ready) : T1 → T2 → T3 → T4 +Sprint 2 (renfort) : T5 → T6 → T7 → T8 +Sprint 3 (hardening) : T9 → T10 → T11 → T12 +``` + +Chaque lot est indépendant et livrable séparément. Aucune tâche n'introduit de changement de rupture d'API. Recommandation : une PR par tâche (ou par lot pour les tâches S), avec les tests de validation associés, et exécution de `go test ./...` avant merge.
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index fb954ca..ce3eb1c 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -76,13 +76,22 @@ func New(st *store.Store, resolver *federation.Resolver, domain, reposDir string } // CreateUser registers a new local user with an initial SSH public key -// (authorized_keys format). +// (authorized_keys format). It clears any lingering revocation for the +// principal or that key, so re-creating a previously-deleted user (or +// re-adding a removed key) restores certificate access. func (a *Admin) CreateUser(username, pubKeyAuthorized string) error { key, err := canonicalAuthorizedKey(pubKeyAuthorized) if err != nil { return err } - return a.Store.CreateUser(store.User{Username: username, PubKeys: []string{key}}) + if err := a.Store.CreateUser(store.User{Username: username, PubKeys: []string{key}}); err != nil { + return err + } + _ = a.Store.UnrevokePrincipal(fmt.Sprintf("%s@%s", username, a.Domain)) + if fp, err := keyFingerprint(key); err == nil { + _ = a.Store.UnrevokeKey(fp) + } + return nil } func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error { @@ -90,15 +99,40 @@ func (a *Admin) AddUserKey(username, pubKeyAuthorized string) error { if err != nil { return err } - return a.Store.AddUserKey(username, key) + if err := a.Store.AddUserKey(username, key); err != nil { + return err + } + if fp, err := keyFingerprint(key); err == nil { + _ = a.Store.UnrevokeKey(fp) + } + return nil } +// RemoveUserKey drops a key from a user and revokes any outstanding +// certificate issued for it, so a removed key can't keep authenticating via a +// still-valid cert until its TTL lapses. func (a *Admin) RemoveUserKey(username, pubKeyAuthorized string) error { key, err := canonicalAuthorizedKey(pubKeyAuthorized) if err != nil { return err } - return a.Store.RemoveUserKey(username, key) + if err := a.Store.RemoveUserKey(username, key); err != nil { + return err + } + if fp, err := keyFingerprint(key); err == nil { + _ = a.Store.RevokeKey(fp) + } + return nil +} + +// keyFingerprint returns the SHA-256 fingerprint of an authorized_keys line, +// matching gossh.FingerprintSHA256(cert.Key) used during cert auth. +func keyFingerprint(pubKeyAuthorized string) (string, error) { + pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(pubKeyAuthorized)) + if err != nil { + return "", err + } + return gossh.FingerprintSHA256(pub), nil } // canonicalAuthorizedKey re-marshals a pasted authorized_keys line (which, @@ -140,6 +174,11 @@ func (a *Admin) DeleteUser(username string) error { if err := a.Store.DeletePasswordHash(username); err != nil && err != store.ErrNotFound { return err } + // Block any certificate this user still holds (valid until its TTL) from + // authenticating now that the account is gone. + if err := a.Store.RevokePrincipal(principal); err != nil { + return err + } for _, r := range repos { if err := a.Store.RemoveCollaborator(r.Name, principal); err != nil && err != store.ErrNotFound { @@ -309,7 +348,27 @@ func (a *Admin) ApproveDomain(domain string) error { return a.Resolver.Approve(domain) } -const minPasswordLength = 8 +const ( + minPasswordLength = 8 + // bcryptCost is above the library default (10) — appropriate for 2026 + // hardware and still well under a noticeable login delay. + bcryptCost = 12 +) + +// dummyHash is a valid bcrypt hash (of an unguessable value) at bcryptCost. +// VerifyPassword compares against it when a user or their credential doesn't +// exist, so a login attempt against a non-existent account takes the same +// time as one against a real account — closing the timing side channel that +// otherwise reveals which usernames exist. +var dummyHash = mustDummyHash() + +func mustDummyHash() []byte { + h, err := bcrypt.GenerateFromPassword([]byte("gitfed-nonexistent-account-sentinel"), bcryptCost) + if err != nil { + panic(err) + } + return h +} // SetPassword hashes and stores newPassword for username, replacing any // existing one. Used both for an admin resetting someone's password and for @@ -319,7 +378,7 @@ func (a *Admin) SetPassword(username, newPassword string) error { if len(newPassword) < minPasswordLength { return fmt.Errorf("admin: password must be at least %d characters", minPasswordLength) } - hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) if err != nil { return fmt.Errorf("admin: hash password: %w", err) } @@ -329,28 +388,33 @@ func (a *Admin) SetPassword(username, newPassword string) error { // VerifyPassword reports whether password matches username's stored hash. // ok is false (with a nil error) for a wrong password or a user with no // password set yet — both are normal login-form outcomes, not failures. +// It always performs one bcrypt comparison, even when the account or hash is +// missing, to keep the response time independent of whether the user exists. func (a *Admin) VerifyPassword(username, password string) (isAdmin bool, ok bool, err error) { user, err := a.Store.GetUser(username) - if err != nil { - if err == store.ErrNotFound { - return false, false, nil - } + if err != nil && err != store.ErrNotFound { return false, false, err } + userExists := err == nil + hash, err := a.Store.GetPasswordHash(username) - if err != nil { - if err == store.ErrNotFound { - return false, false, nil - } + if err != nil && err != store.ErrNotFound { return false, false, err } - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + + compareAgainst := []byte(hash) + if !userExists || hash == "" { + compareAgainst = dummyHash + } + match := bcrypt.CompareHashAndPassword(compareAgainst, []byte(password)) == nil + + if !userExists || hash == "" || !match { return false, false, nil } return user.IsAdmin, true, nil } -const sessionTTL = 30 * 24 * time.Hour +const sessionTTL = 7 * 24 * time.Hour func (a *Admin) CreateSession(principal, username string, isAdmin bool) (string, error) { return a.Store.CreateSession(principal, username, isAdmin, sessionTTL)
internal/admin/revocation_test.go
diff --git a/internal/admin/revocation_test.go b/internal/admin/revocation_test.go new file mode 100644 index 0000000..9bb0495 --- /dev/null +++ b/internal/admin/revocation_test.go @@ -0,0 +1,89 @@ +package admin + +import ( + "crypto/ed25519" + "crypto/rand" + "path/filepath" + "strings" + "testing" + + gossh "golang.org/x/crypto/ssh" + + "gitfed/internal/store" +) + +func newTestKey(t *testing.T) (authorized, fingerprint string) { + t.Helper() + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPub, err := gossh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(sshPub))), gossh.FingerprintSHA256(sshPub) +} + +// TestDeleteUserRevokesPrincipal checks that deleting a user blocks any cert +// they still hold, and that re-creating the account lifts the block. +func TestDeleteUserRevokesPrincipal(t *testing.T) { + s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + a := New(s, nil, "local.test", t.TempDir()) + key, _ := newTestKey(t) + + if err := a.CreateUser("alice", key); err != nil { + t.Fatal(err) + } + if err := a.DeleteUser("alice"); err != nil { + t.Fatal(err) + } + if r, _ := s.IsPrincipalRevoked("alice@local.test"); !r { + t.Fatal("principal should be revoked after DeleteUser") + } + + // Re-creating the account must clear the revocation. + if err := a.CreateUser("alice", key); err != nil { + t.Fatal(err) + } + if r, _ := s.IsPrincipalRevoked("alice@local.test"); r { + t.Fatal("principal should be un-revoked after re-creation") + } +} + +// TestRemoveUserKeyRevokesKey checks that removing a key revokes certs issued +// for it, and that re-adding it lifts the block. +func TestRemoveUserKeyRevokesKey(t *testing.T) { + s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + a := New(s, nil, "local.test", t.TempDir()) + + key1, _ := newTestKey(t) + key2, fp2 := newTestKey(t) + + if err := a.CreateUser("bob", key1); err != nil { + t.Fatal(err) + } + if err := a.AddUserKey("bob", key2); err != nil { + t.Fatal(err) + } + if err := a.RemoveUserKey("bob", key2); err != nil { + t.Fatal(err) + } + if r, _ := s.IsKeyRevoked(fp2); !r { + t.Fatal("key should be revoked after RemoveUserKey") + } + if err := a.AddUserKey("bob", key2); err != nil { + t.Fatal(err) + } + if r, _ := s.IsKeyRevoked(fp2); r { + t.Fatal("key should be un-revoked after re-adding") + } +}
internal/config/config.go
diff --git a/internal/config/config.go b/internal/config/config.go index 8203e7d..1b00c42 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,7 +36,7 @@ func Default(domain, dataDir string) Config { ListenSSH: ":2222", ListenHTTP: ":8443", TrustPolicy: store.TrustPolicyWhitelist, - CertTTLHours: 48, + CertTTLHours: 24, } }
internal/federation/ssrf_test.go
diff --git a/internal/federation/ssrf_test.go b/internal/federation/ssrf_test.go new file mode 100644 index 0000000..78ebc1b --- /dev/null +++ b/internal/federation/ssrf_test.go @@ -0,0 +1,63 @@ +package federation + +import ( + "net" + "testing" +) + +func TestValidatePublicDomain(t *testing.T) { + valid := []string{ + "instanceb.example", + "git.neuromancer.ovh", + "a.b.c.example.com", + } + for _, d := range valid { + if err := ValidatePublicDomain(d); err != nil { + t.Errorf("ValidatePublicDomain(%q) = %v, want nil", d, err) + } + } + + invalid := []string{ + "", // empty + "169.254.169.254", // IP literal (cloud metadata) + "127.0.0.1", // IP literal + "10.0.0.5", // private IP literal + "[::1]", // IPv6 literal + "localhost", // single label, no TLD + "evil.com:6379", // embedded port + "evil.com/path", // embedded path + "user@evil.com", // credentials + "https://evil.com", // scheme + "evil.com#frag", // fragment + "has space.example", // whitespace + } + for _, d := range invalid { + if err := ValidatePublicDomain(d); err == nil { + t.Errorf("ValidatePublicDomain(%q) = nil, want error", d) + } + } +} + +func TestIsDisallowedIP(t *testing.T) { + blocked := []string{ + "127.0.0.1", "::1", // loopback + "10.1.2.3", "172.16.5.5", "192.168.1.1", // RFC1918 + "169.254.169.254", // link-local / cloud metadata + "100.64.0.1", // CGNAT + "0.0.0.0", // unspecified + "fd00::1", // ULA + "fe80::1", // link-local v6 + } + for _, s := range blocked { + if !isDisallowedIP(net.ParseIP(s)) { + t.Errorf("isDisallowedIP(%s) = false, want true", s) + } + } + + allowed := []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "2606:4700:4700::1111"} + for _, s := range allowed { + if isDisallowedIP(net.ParseIP(s)) { + t.Errorf("isDisallowedIP(%s) = true, want false", s) + } + } +}
internal/federation/wellknown.go
diff --git a/internal/federation/wellknown.go b/internal/federation/wellknown.go index 2c90091..f1e17e1 100644 --- a/internal/federation/wellknown.go +++ b/internal/federation/wellknown.go @@ -3,9 +3,14 @@ package federation import ( + "context" "encoding/json" "fmt" + "io" + "net" "net/http" + "regexp" + "strings" "time" "gitfed/internal/ca" @@ -38,19 +43,112 @@ func Handler(domain, contact, softwareVersion string, localCA *ca.CA) http.Handl }) } -var httpClient = &http.Client{Timeout: 10 * time.Second} +// maxWellKnownBytes caps how much of a remote well-known response we read, so +// a hostile or broken peer can't exhaust memory during discovery. +const maxWellKnownBytes = 1 << 20 // 1 MiB + +// insecureClient is used only in dev/testing (insecure=true), where the peer +// is a loopback instance with no TLS and possibly a private address — so it +// deliberately does not apply the anti-SSRF dial guard. +var insecureClient = &http.Client{Timeout: 10 * time.Second} + +// secureClient is used for real remote discovery. Its dialer refuses to +// connect to non-public addresses and pins the connection to the exact IP it +// validated, closing the DNS-rebinding window between check and dial. +var secureClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: guardedDial, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + }, +} + +// hostnameRe matches a conventional public hostname (at least two dot-joined +// labels, alphabetic TLD). It rejects bare hostnames, IP literals and +// anything carrying a port, path, userinfo or scheme. +var hostnameRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`) + +// ValidatePublicDomain reports whether domain is safe to make an outbound +// federation request to: a syntactically valid public hostname, not an IP +// literal and with no embedded port/path/credentials. The IP-level block is +// enforced again at dial time (guardedDial); this is the cheap early gate. +func ValidatePublicDomain(domain string) error { + if domain == "" { + return fmt.Errorf("empty domain") + } + if strings.ContainsAny(domain, ":/?#@\\ ") { + return fmt.Errorf("domain %q must be a bare hostname (no scheme, port, path or credentials)", domain) + } + if net.ParseIP(domain) != nil { + return fmt.Errorf("domain %q must be a hostname, not an IP address", domain) + } + if !hostnameRe.MatchString(domain) { + return fmt.Errorf("domain %q is not a valid public hostname", domain) + } + return nil +} + +// guardedDial resolves the target host, refuses any non-public address, then +// dials the validated IP directly. +func guardedDial(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + d := &net.Dialer{Timeout: 10 * time.Second} + var lastErr error + for _, ip := range ips { + if isDisallowedIP(ip.IP) { + lastErr = fmt.Errorf("refusing to connect to non-public address %s (for %s)", ip.IP, host) + continue + } + conn, err := d.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("no address to dial for %s", host) + } + return nil, lastErr +} + +// isDisallowedIP is true for any address that must never be the target of a +// federation fetch: loopback, RFC1918/ULA private, link-local (covers the +// 169.254.169.254 cloud-metadata endpoint), CGNAT, unspecified and multicast. +func isDisallowedIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() { + return true + } + // Carrier-grade NAT 100.64.0.0/10, not covered by IsPrivate. + if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + return false +} // Fetch retrieves and parses the well-known document for domain over HTTPS. -// insecure switches to plain HTTP, for local development/testing only where -// there's no real DNS name or TLS cert (e.g. federating two instances both -// bound to 127.0.0.1) — never use it against a real remote instance. +// insecure switches to plain HTTP and disables the anti-SSRF guards, for +// local development/testing only where there's no real DNS name or TLS cert +// (e.g. federating two instances both bound to 127.0.0.1) — never use it +// against a real remote instance. func Fetch(domain string, insecure bool) (*WellKnown, error) { - scheme := "https" + scheme, client := "https", secureClient if insecure { - scheme = "http" + scheme, client = "http", insecureClient + } else if err := ValidatePublicDomain(domain); err != nil { + return nil, fmt.Errorf("federation: refusing to fetch %q: %w", domain, err) } + url := fmt.Sprintf("%s://%s/.well-known/gitfed.json", scheme, domain) - resp, err := httpClient.Get(url) + resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("federation: fetch %s: %w", url, err) } @@ -61,7 +159,7 @@ func Fetch(domain string, insecure bool) (*WellKnown, error) { } var doc WellKnown - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, maxWellKnownBytes)).Decode(&doc); err != nil { return nil, fmt.Errorf("federation: decode %s: %w", url, err) } if doc.Domain != domain {
internal/i18n/i18n.go
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..b579d7e --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,55 @@ +// Package i18n holds gitfed-web's translation strings and looks them up by +// key. Every page's copy lives here instead of being hardcoded in the Go +// templates, so the web UI can be served in more than one language. +package i18n + +import "fmt" + +// Lang is a supported UI language. New languages are added by defining a +// constant here and a dictionary for it in dict(). +type Lang string + +const ( + EN Lang = "en" + FR Lang = "fr" +) + +// Default is used whenever a request carries no usable language signal +// (no cookie, no matching Accept-Language tag). +const Default = EN + +// ParseLang validates a language tag from a cookie or path segment, +// returning ok=false for anything gitfed doesn't have a dictionary for. +func ParseLang(v string) (Lang, bool) { + switch Lang(v) { + case EN, FR: + return Lang(v), true + default: + return "", false + } +} + +func dict(l Lang) map[string]string { + if l == FR { + return fr + } + return en +} + +// T looks up key in lang's dictionary, falling back to English and then to +// the key itself so a missing translation shows up as an obviously-wrong +// string instead of a blank. With extra args, the result is passed through +// fmt.Sprintf. +func T(lang Lang, key string, args ...any) string { + s, ok := dict(lang)[key] + if !ok { + s, ok = en[key] + } + if !ok { + s = key + } + if len(args) > 0 { + return fmt.Sprintf(s, args...) + } + return s +}
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go new file mode 100644 index 0000000..fb91dbd --- /dev/null +++ b/internal/i18n/strings_en.go @@ -0,0 +1,247 @@ +package i18n + +var en = map[string]string{ + // ---------- nav / shell ---------- + "nav.home": "Home", + "nav.explore": "Explore", + "nav.security": "Security", + "nav.dashboard": "Dashboard", + "nav.search_placeholder": "Search repos…", + "nav.login": "Log in", + "nav.signed_in_as": "Signed in as", + "nav.settings": "Settings", + "nav.admin": "Admin", + "nav.logout": "Log out", + "footer.changelog": "gitfed %s", + + // ---------- explore ---------- + "explore.title": "Explore public repositories", + "explore.filtered_by": "Filtered by topic", + "explore.clear": "clear", + "explore.col_repo": "Repo", + "explore.col_owner": "Owner", + "explore.col_topics": "Topics", + "explore.empty": "No public repositories yet.", + + // ---------- landing ---------- + "landing.title": "gitfed", + "landing.kicker": "self-hosted & federated git", + "landing.h1_1": "Your code. Your server.", + "landing.h1_2": "Your rules.", + "landing.lead": "gitfed is a git server you run yourself — so you keep ownership of your code and your data, without giving up the ability to collaborate with anyone, wherever they host their own instance.", + "landing.cta_explore": "Explore public repos", + "landing.cta_security": "How it's secured →", + "landing.pillar1_h": "Ownership", + "landing.pillar1_p": "Your code and your data stay on your machine, your server, your infrastructure. No third-party platform can mine it, shut it down, or cut off your access.", + "landing.pillar2_h": "Federation", + "landing.pillar2_p": "Each instance stays independent, but can trust others — a bit like email. One account, on your own instance, is enough to push code to an instance that trusts you.", + "landing.pillar3_h": "Control", + "landing.pillar3_p": "Public or private, read or write, per repo, per person. You decide — no platform-wide policy gets imposed on you.", + "landing.split_h": "One account at home, not one everywhere", + "landing.split_p": "When someone from another instance wants to collaborate on one of your repos, they don't create an account with you: their home instance signs a certificate proving who they are, and your instance checks that it trusts that instance. Once trust is established, everything happens locally — no network call on every `git push`.", + "landing.split_link_security": "More on security", + "landing.split_link_explore": "Explore repos", + "landing.diagram_you": "Your instance", + "landing.diagram_friend": "A friend's instance", + "landing.diagram_cert": "certificate", + "landing.diagram_trust": "trust verified", + "landing.diagram_no_account1": "no \"bob\" account", + "landing.diagram_no_account2": "on alice's instance", + "landing.cta_band_h": "Ready to take back control of your code?", + "landing.cta_band_p": "gitfed is open-source and self-hosted — one Go binary, one embedded database, no external dependencies.", + "landing.cta_band_dashboard": "Go to your dashboard", + + // ---------- security ---------- + "security.title": "Security — gitfed", + "security.kicker": "security model", + "security.h1": "Why it's secure, explained simply", + "security.lead": "No unnecessary jargon: here's, step by step, how gitfed protects your code — from transport to your collaborators' identity.", + + "security.s1_tag": "01 · transport", + "security.s1_h": "SSH only, never git over HTTP", + "security.s1_p": "gitfed exposes no git-over-HTTP protocol. Every read or write goes over SSH — a protocol proven for 25 years, with a single door to watch instead of two.", + "security.s1_diagram_client": "git clone/push", + "security.s1_diagram_open": "SSH — open", + "security.s1_diagram_http": "HTTP git — doesn't exist", + "security.s1_diagram_repos": "repos", + "security.s1_caption": "A single network entry point for git: the SSH port.", + + "security.s2_tag": "02 · identity", + "security.s2_h": "Short-lived certificates, not shared passwords", + "security.s2_p": "Every instance generates its own certificate authority. Your instance signs a certificate proving who you are, valid for 24h by default — never a long-lived secret that can leak.", + "security.s2_diagram_haskey": "has an SSH key", + "security.s2_diagram_step1": "1. requests", + "security.s2_diagram_alice_instance": "alice's instance", + "security.s2_diagram_local_ca": "local authority (CA)", + "security.s2_diagram_signs": "signs 24h", + "security.s2_diagram_step2": "2. certificate", + "security.s2_diagram_bob_instance": "bob's instance", + "security.s2_diagram_trust1": "does it trust", + "security.s2_diagram_trust2": "this CA?", + "security.s2_diagram_access": "access", + "security.s2_diagram_local1": "verification is 100% local from here —", + "security.s2_diagram_local2": "no network call on every push", + "security.s2_caption": "alice asks her own instance for a certificate, presents it to bob's, which checks its trust in alice's instance.", + + "security.s3_tag": "03 · trust", + "security.s3_h": "Trust between instances is explicit, never automatic", + "security.s3_p": "The first time an unknown instance shows up, gitfed reads its public profile (/.well-known/gitfed.json) then puts its key on hold — an admin has to approve it before any access is granted. Discovery is also capped at 10 new domains per minute, to prevent abuse.", + "security.s3_diagram_unknown": "Unknown domain", + "security.s3_diagram_unknown_sub": "contacts your instance", + "security.s3_diagram_discovery": "Discovery", + "security.s3_diagram_discovery_sub": "reads its public CA key", + "security.s3_diagram_pending": "Pending", + "security.s3_diagram_pending_sub": "until an admin acts", + "security.s3_diagram_approved": "Approved", + "security.s3_diagram_approved_sub": "an admin's decision", + + "security.s4_tag": "04 · isolation", + "security.s4_h": "Web access and git access share nothing", + "security.s4_p": "The web UI password has nothing to do with your git access: it's hashed (bcrypt), never stored in clear text, and only opens an opaque, time-limited web session. A `git push` never depends on this password — only on your SSH key or certificate.", + "security.s4_fact1_h": "Precise per-repo roles", + "security.s4_fact1_p": "read, write, admin — never all-or-nothing.", + "security.s4_fact2_h": "Public ≠ writable", + "security.s4_fact2_p": "a public repo opens for reading, never for writing without an explicit grant.", + "security.s4_fact3_h": "Session cookies", + "security.s4_fact3_p": "HttpOnly, Secure, SameSite — unreachable from JavaScript.", + "security.s4_fact4_h": "Audit log", + "security.s4_fact4_p": "every access decision, allowed or denied, is recorded.", + + // ---------- common ---------- + "common.public": "public", + "common.private": "private", + "role.owner": "owner", + "role.read": "read", + "role.write": "write", + "role.admin": "admin", + + // ---------- dashboard ---------- + "dashboard.title": "Your repos", + "dashboard.stat_repos": "Repos", + "dashboard.stat_public": "Public", + "dashboard.stat_shared": "Shared with you", + "dashboard.new_repo": "New repo", + "dashboard.repo_name": "Name", + "dashboard.create": "Create", + "dashboard.empty": "No repos yet — create one above.", + "dashboard.msg_created": "created %s", + "dashboard.msg_name_required": "repository name is required", + "dashboard.msg_bad_namespace": "you can only create repositories in your own namespace", + "dashboard.msg_quota": "you have reached the maximum number of repositories", + + // ---------- settings ---------- + "settings.title": "Settings", + "settings.tab_profile": "Profile", + "settings.tab_keys": "SSH keys", + "settings.tab_password": "Password", + "settings.logged_in_as": "Logged in as", + "settings.profile_note": "This password only signs you into the web UI. Git push/pull always goes over SSH with a key, independently of it.", + "settings.remove": "Remove", + "settings.no_keys": "No keys yet.", + "settings.add_key": "Add SSH key", + "settings.pubkey_label": "Public key (authorized_keys format)", + "settings.add": "Add", + "settings.change_password": "Change password", + "settings.current_password": "Current password", + "settings.new_password": "New password", + "settings.msg_key_added": "key added", + "settings.msg_key_removed": "key removed", + "settings.msg_wrong_password": "current password is incorrect", + "settings.msg_password_changed": "password changed", + + // ---------- auth ---------- + "auth.login": "Log in", + "auth.username": "Username", + "auth.password": "Password", + "auth.note": "Accounts are created by an instance admin — there's no self-registration. This only logs you into the web UI; git push/pull still goes over SSH with your key.", + "auth.invalid_login": "invalid username or password", + "auth.rate_limited": "too many login attempts, try again in a few minutes", + "auth.error": "unable to log you in right now, try again later", + "common.server_error": "an internal error occurred", + + // ---------- search / changelog ---------- + "search.title": "Search", + "search.no_matches": "No matches.", + "search.prompt": "Type something to search.", + "changelog.title": "Changelog", + + // ---------- admin ---------- + "admin.title": "Admin", + "admin.card_users": "Users", + "admin.card_users_sub": "%d admin(s), %d regular", + "admin.card_users_link": "Manage users", + "admin.card_trust": "Trust store", + "admin.card_trust_pending": "pending", + "admin.card_trust_sub": "federated domains known", + "admin.card_trust_link": "Review trust store", + "admin.card_audit": "Audit log", + "admin.card_audit_sub": "most recent events on record", + "admin.card_audit_link": "View audit log", + "admin.recent_activity": "Recent activity", + "admin.audit_allow": "allow", + "admin.audit_deny": "deny", + + "admin.users_title": "Admin — Users", + "admin.users_col_username": "Username", + "admin.users_col_keys": "Keys", + "admin.revoke_admin": "Revoke admin", + "admin.make_admin": "Make admin", + "admin.confirm_delete_user": "Delete user", + "admin.users_empty": "No users yet.", + "admin.add_user": "Add user", + "admin.initial_password": "Initial password", + "admin.grant_admin": "Grant admin", + "admin.msg_user_created": "created user %s", + "admin.msg_user_deleted": "deleted user %s", + "admin.msg_user_updated": "updated %s", + + "admin.trust_title": "Admin — Trust store", + "admin.trust_col_domain": "Domain", + "admin.trust_col_status": "Status", + "admin.trust_col_first_seen": "First seen", + "admin.trust_status_pending": "pending", + "admin.trust_status_trusted": "trusted", + "admin.approve": "Approve", + "admin.trust_empty": "No remote domains discovered yet.", + "admin.trust_note": "Domains show up here automatically the first time a collaborator on a remote domain is granted access to a repo. Under the default whitelist policy they stay pending until approved here.", + "admin.msg_domain_approved": "approved %s", + + "admin.audit_title": "Admin — Audit log", + "admin.audit_col_time": "Time", + "admin.audit_col_action": "Action", + "admin.audit_col_principal": "Principal", + "admin.audit_col_repo_domain": "Repo/Domain", + "admin.audit_col_result": "Result", + "admin.audit_col_detail": "Detail", + "admin.audit_empty": "No events recorded yet.", + "admin.audit_note": "Showing the most recent 200 events.", + + // ---------- repo ---------- + "repo.owner": "owner", + "repo.copy_clone_url": "Copy clone URL", + "repo.empty": "This repository is empty.", + "repo.directory": "directory", + "repo.file": "file", + "repo.nothing_here": "Nothing here.", + "repo.tags": "Tags", + "repo.license": "License", + "repo.binary_file": "Binary file — not shown.", + "repo.settings_title": "settings", + "repo.visibility_topics": "Visibility & topics", + "repo.public_desc": "Public (readable by any authenticated principal)", + "repo.topics_label": "Topics (comma-separated)", + "repo.save": "Save", + "repo.col_principal": "Principal", + "repo.col_role": "Role", + "repo.revoke": "Revoke", + "repo.grant_collaborator": "Grant collaborator", + "repo.grant": "Grant", + "repo.grant_note": "Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).", + "repo.confirm_delete": "Delete", + "repo.danger_zone": "Danger zone", + "repo.delete_record": "Delete repo record", + "repo.msg_saved": "saved settings", + "repo.msg_granted": "granted %s %s", + "repo.msg_revoked": "revoked %s", + "repo.msg_deleted": "deleted %s", +}
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go new file mode 100644 index 0000000..9896e4a --- /dev/null +++ b/internal/i18n/strings_fr.go @@ -0,0 +1,247 @@ +package i18n + +var fr = map[string]string{ + // ---------- nav / shell ---------- + "nav.home": "Accueil", + "nav.explore": "Explorer", + "nav.security": "Sécurité", + "nav.dashboard": "Tableau de bord", + "nav.search_placeholder": "Rechercher un dépôt…", + "nav.login": "Se connecter", + "nav.signed_in_as": "Connecté en tant que", + "nav.settings": "Paramètres", + "nav.admin": "Administration", + "nav.logout": "Se déconnecter", + "footer.changelog": "gitfed %s", + + // ---------- explore ---------- + "explore.title": "Explorer les dépôts publics", + "explore.filtered_by": "Filtré par sujet", + "explore.clear": "effacer", + "explore.col_repo": "Dépôt", + "explore.col_owner": "Propriétaire", + "explore.col_topics": "Sujets", + "explore.empty": "Aucun dépôt public pour le moment.", + + // ---------- landing ---------- + "landing.title": "gitfed", + "landing.kicker": "git auto-hébergé & fédéré", + "landing.h1_1": "Votre code. Votre serveur.", + "landing.h1_2": "Vos règles.", + "landing.lead": "gitfed est un serveur git que vous hébergez vous-même — pour garder la propriété de votre code et de vos données, sans renoncer à collaborer avec qui vous voulez, où que cette personne héberge son instance.", + "landing.cta_explore": "Explorer les dépôts publics", + "landing.cta_security": "Comment c'est sécurisé →", + "landing.pillar1_h": "Propriété", + "landing.pillar1_p": "Votre code et vos données restent sur votre machine, votre serveur, votre infrastructure. Aucune plateforme tierce ne peut les exploiter, les fermer, ou vous couper l'accès.", + "landing.pillar2_h": "Fédération", + "landing.pillar2_p": "Chaque instance reste indépendante, mais peut faire confiance à d'autres — comme le mail. Un seul compte, chez vous, suffit pour pousser du code sur une instance qui vous fait confiance.", + "landing.pillar3_h": "Contrôle", + "landing.pillar3_p": "Public ou privé, lecture ou écriture, dépôt par dépôt, personne par personne. C'est vous qui décidez — aucune règle de plateforme ne s'impose à vous.", + "landing.split_h": "Un compte chez vous, pas un compte partout", + "landing.split_p": "Quand une personne d'une autre instance veut collaborer sur un de vos dépôts, elle ne crée pas de compte chez vous : son instance d'origine signe un certificat qui prouve qui elle est, et votre instance vérifie qu'elle fait confiance à cette instance-là. Une fois la confiance établie, tout se passe localement — aucun appel réseau à chaque `git push`.", + "landing.split_link_security": "En savoir plus sur la sécurité", + "landing.split_link_explore": "Explorer les dépôts", + "landing.diagram_you": "Votre instance", + "landing.diagram_friend": "Instance d'un ami", + "landing.diagram_cert": "certificat", + "landing.diagram_trust": "confiance vérifiée", + "landing.diagram_no_account1": "pas de compte « bob »", + "landing.diagram_no_account2": "chez alice", + "landing.cta_band_h": "Envie de reprendre la main sur votre code ?", + "landing.cta_band_p": "gitfed est open-source et auto-hébergé — un binaire Go, une base embarquée, aucune dépendance externe.", + "landing.cta_band_dashboard": "Aller au tableau de bord", + + // ---------- security ---------- + "security.title": "Sécurité — gitfed", + "security.kicker": "modèle de sécurité", + "security.h1": "Pourquoi c'est sécurisé, expliqué simplement", + "security.lead": "Pas de jargon inutile : voici, étape par étape, comment gitfed protège votre code — du transport jusqu'à l'identité de vos collaborateurs.", + + "security.s1_tag": "01 · transport", + "security.s1_h": "Uniquement du SSH, jamais du git en HTTP", + "security.s1_p": "gitfed n'expose aucun protocole git par HTTP. Toute lecture ou écriture passe par SSH — un protocole éprouvé depuis 25 ans, avec une seule porte d'entrée à surveiller plutôt que deux.", + "security.s1_diagram_client": "git clone/push", + "security.s1_diagram_open": "SSH — ouvert", + "security.s1_diagram_http": "HTTP git — inexistant", + "security.s1_diagram_repos": "dépôts", + "security.s1_caption": "Une seule porte d'entrée réseau pour le git : le port SSH.", + + "security.s2_tag": "02 · identité", + "security.s2_h": "Des certificats de courte durée, pas des mots de passe partagés", + "security.s2_p": "Chaque instance génère sa propre autorité de certification. Votre instance signe un certificat prouvant qui vous êtes, valable 24h par défaut — jamais un secret longue durée à faire fuiter.", + "security.s2_diagram_haskey": "a une clé SSH", + "security.s2_diagram_step1": "1. demande", + "security.s2_diagram_alice_instance": "instance d'alice", + "security.s2_diagram_local_ca": "autorité (CA) locale", + "security.s2_diagram_signs": "signe 24h", + "security.s2_diagram_step2": "2. certificat", + "security.s2_diagram_bob_instance": "instance de bob", + "security.s2_diagram_trust1": "a-t-elle confiance", + "security.s2_diagram_trust2": "en cette CA ?", + "security.s2_diagram_access": "accès", + "security.s2_diagram_local1": "vérification 100% locale ensuite —", + "security.s2_diagram_local2": "aucun appel réseau à chaque push", + "security.s2_caption": "alice demande un certificat à sa propre instance, le présente chez bob, qui vérifie sa confiance envers l'instance d'alice.", + + "security.s3_tag": "03 · confiance", + "security.s3_h": "La confiance entre instances est explicite, jamais automatique", + "security.s3_p": "La première fois qu'une instance inconnue apparaît, gitfed consulte sa fiche publique (/.well-known/gitfed.json) puis met sa clé en attente — un administrateur doit l'approuver avant que le moindre accès soit accordé. La découverte est aussi limitée à 10 nouveaux domaines par minute, pour éviter tout abus.", + "security.s3_diagram_unknown": "Domaine inconnu", + "security.s3_diagram_unknown_sub": "contacte votre instance", + "security.s3_diagram_discovery": "Découverte", + "security.s3_diagram_discovery_sub": "lit sa clé publique CA", + "security.s3_diagram_pending": "En attente", + "security.s3_diagram_pending_sub": "tant qu'un admin n'a rien fait", + "security.s3_diagram_approved": "Approuvé", + "security.s3_diagram_approved_sub": "décision d'un admin", + + "security.s4_tag": "04 · cloisonnement", + "security.s4_h": "L'accès web et l'accès git ne partagent rien", + "security.s4_p": "Le mot de passe de l'interface web n'a rien à voir avec vos accès git : il est haché (bcrypt), jamais stocké en clair, et n'ouvre qu'une session web opaque, à durée limitée. Un `git push` ne dépend jamais de ce mot de passe — uniquement de votre clé SSH ou de votre certificat.", + "security.s4_fact1_h": "Rôles précis par dépôt", + "security.s4_fact1_p": "lecture, écriture, admin, jamais tout ou rien.", + "security.s4_fact2_h": "Public ≠ inscriptible", + "security.s4_fact2_p": "un dépôt public s'ouvre en lecture, jamais en écriture sans droit explicite.", + "security.s4_fact3_h": "Cookies de session", + "security.s4_fact3_p": "HttpOnly, Secure, SameSite — inaccessibles en JavaScript.", + "security.s4_fact4_h": "Journal d'audit", + "security.s4_fact4_p": "chaque décision d'accès, autorisée ou refusée, est enregistrée.", + + // ---------- common ---------- + "common.public": "public", + "common.private": "privé", + "role.owner": "propriétaire", + "role.read": "lecture", + "role.write": "écriture", + "role.admin": "admin", + + // ---------- dashboard ---------- + "dashboard.title": "Vos dépôts", + "dashboard.stat_repos": "Dépôts", + "dashboard.stat_public": "Publics", + "dashboard.stat_shared": "Partagés avec vous", + "dashboard.new_repo": "Nouveau dépôt", + "dashboard.repo_name": "Nom", + "dashboard.create": "Créer", + "dashboard.empty": "Aucun dépôt pour le moment — créez-en un ci-dessus.", + "dashboard.msg_created": "%s créé", + "dashboard.msg_name_required": "le nom du dépôt est obligatoire", + "dashboard.msg_bad_namespace": "vous ne pouvez créer des dépôts que dans votre propre espace de noms", + "dashboard.msg_quota": "vous avez atteint le nombre maximum de dépôts", + + // ---------- settings ---------- + "settings.title": "Paramètres", + "settings.tab_profile": "Profil", + "settings.tab_keys": "Clés SSH", + "settings.tab_password": "Mot de passe", + "settings.logged_in_as": "Connecté en tant que", + "settings.profile_note": "Ce mot de passe ne sert qu'à se connecter à l'interface web. Le push/pull git passe toujours par SSH avec une clé, indépendamment de lui.", + "settings.remove": "Retirer", + "settings.no_keys": "Aucune clé pour le moment.", + "settings.add_key": "Ajouter une clé SSH", + "settings.pubkey_label": "Clé publique (format authorized_keys)", + "settings.add": "Ajouter", + "settings.change_password": "Changer le mot de passe", + "settings.current_password": "Mot de passe actuel", + "settings.new_password": "Nouveau mot de passe", + "settings.msg_key_added": "clé ajoutée", + "settings.msg_key_removed": "clé retirée", + "settings.msg_wrong_password": "mot de passe actuel incorrect", + "settings.msg_password_changed": "mot de passe changé", + + // ---------- auth ---------- + "auth.login": "Se connecter", + "auth.username": "Nom d'utilisateur", + "auth.password": "Mot de passe", + "auth.note": "Les comptes sont créés par un administrateur de l'instance — pas d'inscription libre. Ceci ne fait que vous connecter à l'interface web ; le push/pull git passe toujours par SSH avec votre clé.", + "auth.invalid_login": "nom d'utilisateur ou mot de passe invalide", + "auth.rate_limited": "trop de tentatives de connexion, réessayez dans quelques minutes", + "auth.error": "impossible de vous connecter pour le moment, réessayez plus tard", + "common.server_error": "une erreur interne est survenue", + + // ---------- search / changelog ---------- + "search.title": "Recherche", + "search.no_matches": "Aucun résultat.", + "search.prompt": "Tapez quelque chose pour rechercher.", + "changelog.title": "Changelog", + + // ---------- admin ---------- + "admin.title": "Administration", + "admin.card_users": "Utilisateurs", + "admin.card_users_sub": "%d admin(s), %d classique(s)", + "admin.card_users_link": "Gérer les utilisateurs", + "admin.card_trust": "Confiance fédérée", + "admin.card_trust_pending": "en attente", + "admin.card_trust_sub": "domaines fédérés connus", + "admin.card_trust_link": "Revoir la confiance fédérée",

This commit's diff is too large to show in full — the list above still shows every changed file.