diff --git a/endpoints_test.go b/endpoints_test.go index 949ccc9..fe8b6be 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "path/filepath" + "strings" "testing" ) @@ -552,3 +553,39 @@ func TestDeleteUser(t *testing.T) { t.Fatalf("bobs antwort verwaist: %+v", tv.Replies) } } + +// postFormFrom verhält sich wie postForm, setzt aber X-Real-IP -> middleware.RealIP +// überschreibt damit r.RemoteAddr, sodass blockTOR die simulierte Quell-IP sieht. +func postFormFrom(t *testing.T, c *http.Client, urlStr, realIP string, form url.Values) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, urlStr, strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("request %s: %v", urlStr, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Real-IP", realIP) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", urlStr, err) + } + return resp +} + +// TestBlockTOREntry prüft, dass Beiträge aus dem TOR-Netz (Default 10.89.0.0/16) +// mit 403 abgewiesen werden, aus einem anderen Netz aber durchgehen. +func TestBlockTOREntry(t *testing.T) { + srv := newTestServer(t) + c := registerAndLogin(t, srv, "toruser") + + resp := postFormFrom(t, c, srv.URL+"/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode) + } + + resp = postFormFrom(t, c, srv.URL+"/entry/create", "203.0.113.7", url.Values{"content": {"normal"}}) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode) + } +} diff --git a/main.go b/main.go index bf67587..706e805 100644 --- a/main.go +++ b/main.go @@ -113,7 +113,7 @@ func routes() http.Handler { // --- Geschützt (Session erforderlich) --- r.Group(func(r chi.Router) { r.Use(requireAuth) - r.Post("/entry/create", handleCreateEntry) + r.With(blockTOR).Post("/entry/create", handleCreateEntry) r.Post("/entry/{pid}/vote", handleVote) r.Post("/entry/{pid}/bump", handleBump) r.Post("/entry/{pid}/edit", handleEditEntry) diff --git a/middleware.go b/middleware.go index 72f47e1..a8c649c 100644 --- a/middleware.go +++ b/middleware.go @@ -2,6 +2,8 @@ package main import ( "context" + "log" + "net" "net/http" ) @@ -9,6 +11,41 @@ type ctxKey string const uidKey ctxKey = "uid" +// torNet ist das interne Netz, über das TOR-Anfragen beim Backend ankommen: +// der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte +// Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus +// diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden +// vorerst komplett abgewiesen; eine Moderations-/Freigabeseite kommt später. +// Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab. +var torNet *net.IPNet + +func init() { + cidr := envOr("KVER_TOR_NET", "10.89.0.0/16") + if cidr == "" { + return + } + if _, n, err := net.ParseCIDR(cidr); err == nil { + torNet = n + } else { + log.Printf("KVER_TOR_NET ignoriert (ungültiges CIDR %q): %v", cidr, err) + } +} + +// blockTOR weist Anfragen aus dem TOR-Netz ab (siehe torNet). Die volle IP wird +// hier nur für diese Zugriffsentscheidung geprüft, nicht gespeichert oder +// geloggt. Ist kein Netz konfiguriert, ist die Middleware ein No-op. +func blockTOR(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if torNet != nil { + if ip := clientIP(r.RemoteAddr); ip != nil && torNet.Contains(ip) { + writeError(w, http.StatusForbidden, "Beiträge über TOR sind derzeit nicht möglich.") + return + } + } + next.ServeHTTP(w, r) + }) +} + // requireAuth lehnt Anfragen ohne gültige Session ab und legt die uid in den Context. func requireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {