diff --git a/auth.go b/auth.go index 9a179b7..c981a0a 100644 --- a/auth.go +++ b/auth.go @@ -79,7 +79,16 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { } now := time.Now().Unix() + // timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine + // quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage. timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64) + const maxTimeout = 30 * 86400 + if timeoutSec <= 0 { + timeoutSec = 86400 + } + if timeoutSec > maxTimeout { + timeoutSec = maxTimeout + } expires := now + timeoutSec token, err := newToken() @@ -104,6 +113,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { Expires: time.Unix(expires, 0), Path: "/", HttpOnly: true, + Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) @@ -114,11 +124,14 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { db.Exec(`DELETE FROM session WHERE value = ?`, s.Value) } http.SetCookie(w, &http.Cookie{ - Name: "session", - Value: "", - Path: "/", - Expires: time.Unix(0, 0), - MaxAge: -1, + Name: "session", + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: isHTTPS(r), + SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } @@ -184,6 +197,13 @@ func sanitizeUsername(u string) string { return u } +// isHTTPS erkennt, ob die Anfrage über TLS kam — direkt oder über einen +// TLS-terminierenden Reverse-Proxy (X-Forwarded-Proto). Steuert das Secure-Flag, +// ohne lokale Tests über http zu brechen. +func isHTTPS(r *http.Request) bool { + return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" +} + func newToken() (string, error) { b := make([]byte, 32) if _, err := crand.Read(b); err != nil { diff --git a/db.go b/db.go index bb3e0f1..bf37a44 100644 --- a/db.go +++ b/db.go @@ -51,7 +51,9 @@ CREATE TABLE IF NOT EXISTS vote ( func initDB(dsn string) error { var err error - db, err = sql.Open("sqlite", dsn) + // SQLite hat genau einen Schreiber: busy_timeout wartet bei Sperren, statt + // sofort mit SQLITE_BUSY zu scheitern; WAL erlaubt parallele Leser. + db, err = sql.Open("sqlite", dsn+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)") if err != nil { return err } diff --git a/entry.go b/entry.go index 816015f..1ec08a3 100644 --- a/entry.go +++ b/entry.go @@ -7,6 +7,7 @@ import ( "image/gif" "image/jpeg" _ "image/png" + "io" "math/rand/v2" "mime/multipart" "net/http" @@ -48,7 +49,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) { var e Entry if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username); err != nil { - continue + return nil, err } entries = append(entries, e) } @@ -393,6 +394,12 @@ func handleVote(w http.ResponseWriter, r *http.Request) { return } + // Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes. + if _, err := entryByPID(pid); err != nil { + writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") + return + } + var prev string readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev) if readErr != nil && readErr != sql.ErrNoRows { @@ -428,6 +435,21 @@ func storeImage(fh *multipart.FileHeader) (string, error) { } defer src.Close() + // Dimensionen aus dem Header prüfen, bevor wir die vollen Pixel allokieren: + // eine kleine Datei kann riesige Maße deklarieren (Decompression-Bomb) und + // sonst beim Decode den Speicher sprengen. + const maxPixels = 50_000_000 // ~50 MP + cfg, _, err := image.DecodeConfig(src) + if err != nil { + return "", err + } + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxPixels { + return "", fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height) + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", err + } + img, format, err := image.Decode(src) if err != nil { return "", err diff --git a/go.mod b/go.mod index 8b329cb..1a84e92 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.0 + github.com/go-chi/httprate v0.15.0 golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 modernc.org/sqlite v1.51.0 @@ -12,9 +13,11 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/sys v0.45.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index df930d1..2faa3f5 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,26 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= diff --git a/main.go b/main.go index 59243c3..120bc9e 100644 --- a/main.go +++ b/main.go @@ -4,9 +4,11 @@ import ( "encoding/json" "log" "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" ) func main() { @@ -28,9 +30,14 @@ func routes() http.Handler { r.Use(middleware.Recoverer) // --- Auth (öffentlich) --- - r.Post("/auth/login", handleLogin) + // Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst + // zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh. + r.Group(func(r chi.Router) { + r.Use(httprate.LimitByIP(20, time.Minute)) + r.Post("/auth/login", handleLogin) + r.Post("/auth/newuser", handleNewUser) + }) r.Get("/auth/logout", handleLogout) - r.Post("/auth/newuser", handleNewUser) r.Get("/auth/sessioninfo", handleSessionInfo) r.Get("/auth/headerbar", handleHeaderbar) diff --git a/user.go b/user.go index 8542341..227d25c 100644 --- a/user.go +++ b/user.go @@ -12,7 +12,6 @@ import ( ) func createUser(username, password string) error { - uid := int64(rand.IntN(99999999)) now := time.Now().Unix() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -20,11 +19,22 @@ func createUser(username, password string) error { return err } - _, err = db.Exec( - `INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`, - uid, now, username, hash, - ) - return err + // uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry, + // uidFromContext). Bei der seltenen UNIQUE-Kollision auf uid neu würfeln. + var lastErr error + for i := 0; i < 5; i++ { + uid := int64(rand.IntN(99999999)) + 1 + _, lastErr = db.Exec( + `INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`, + uid, now, username, hash, + ) + if lastErr == nil { + return nil + } + // username ist ebenfalls UNIQUE und wird vom Aufrufer vorab geprüft; + // eine Kollision hier kann also nur die uid sein -> erneut versuchen. + } + return lastErr } // idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert. @@ -169,11 +179,14 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, &http.Cookie{ - Name: "session", - Value: "", - Path: "/", - Expires: time.Unix(0, 0), - MaxAge: -1, + Name: "session", + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + Secure: isHTTPS(r), + SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) }